feat(orchestration): add inter-agent orchestration system (#1188)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-04-28 12:21:31 -07:00 committed by GitHub
parent 2c494da361
commit c9391e203f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 5407 additions and 72 deletions

View File

@ -55,6 +55,12 @@ jobs:
- name: Typecheck
run: pnpm typecheck
# Why: postinstall rebuilds better-sqlite3 for Electron's ABI via
# @electron/rebuild, but vitest runs under system Node.js. Rebuild
# it for Node so orchestration tests can load the native module.
- name: Rebuild better-sqlite3 for Node
run: pnpm rebuild better-sqlite3
- name: Test
run: pnpm test

28
bin/orca-dev Executable file
View File

@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Dev CLI wrapper — uses a separate userData path to avoid clobbering production Orca data.
set -euo pipefail
# Resolve through symlinks so this works when called via /usr/local/bin/orca-dev
SOURCE="$0"
while [ -L "$SOURCE" ]; do
DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
SOURCE="$(readlink "$SOURCE")"
[[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
done
SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI_ENTRY="$REPO_ROOT/out/cli/index.js"
if [ ! -f "$CLI_ENTRY" ]; then
echo "orca-dev: CLI not built yet. Run 'pnpm run build:cli' first." >&2
exit 1
fi
case "$(uname -s)" in
Darwin) default_data="$HOME/Library/Application Support/orca-dev" ;;
MINGW*|MSYS*) default_data="${APPDATA:-$HOME/AppData/Roaming}/orca-dev" ;;
*) default_data="${XDG_CONFIG_HOME:-$HOME/.config}/orca-dev" ;;
esac
export ORCA_USER_DATA_PATH="${ORCA_DEV_USER_DATA_PATH:-$default_data}"
exec node "$CLI_ENTRY" "$@"

View File

@ -0,0 +1,50 @@
#!/usr/bin/env node
// Symlinks the orca-dev wrapper into /usr/local/bin so the dev CLI is
// available globally after `pnpm run build:cli`.
import { existsSync, lstatSync, readlinkSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
const source = path.join(repoRoot, 'bin', 'orca-dev')
const commandPath =
process.platform === 'darwin' || process.platform === 'linux'
? '/usr/local/bin/orca-dev'
: null
if (!commandPath) {
console.log('[orca-dev] Skipping global symlink (unsupported platform).')
process.exit(0)
}
function isOwnedByUs(target) {
try {
if (!lstatSync(target).isSymbolicLink()) { return false }
return readlinkSync(target) === source
} catch {
return false
}
}
if (existsSync(commandPath)) {
if (isOwnedByUs(commandPath)) {
console.log(`[orca-dev] ${commandPath} already points to dev CLI.`)
process.exit(0)
}
console.error(
`[orca-dev] ${commandPath} exists but is not our symlink. Remove it manually if you want the dev CLI installed globally.`
)
process.exit(0)
}
try {
execFileSync('ln', ['-s', source, commandPath], { stdio: 'inherit' })
console.log(`[orca-dev] Symlinked ${commandPath}${source}`)
} catch {
console.log(
`[orca-dev] Could not create ${commandPath} (permission denied). Run once with:\n` +
` sudo ln -s ${source} ${commandPath}`
)
}

View File

@ -1,6 +1,8 @@
import { spawn } from 'node:child_process'
import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
// Why: Electron-based hosts (e.g. Claude Code, VS Code) set
// ELECTRON_RUN_AS_NODE=1 in their terminal environment. If this leaks into
@ -9,6 +11,51 @@ import path from 'node:path'
delete process.env.ELECTRON_RUN_AS_NODE
const require = createRequire(import.meta.url)
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
function getDevUserDataPath() {
if (process.env.ORCA_DEV_USER_DATA_PATH) {
return process.env.ORCA_DEV_USER_DATA_PATH
}
if (process.platform === 'darwin') {
return path.join(process.env.HOME ?? '', 'Library', 'Application Support', 'orca-dev')
}
if (process.platform === 'win32') {
return path.join(process.env.APPDATA ?? path.join(process.env.USERPROFILE ?? '', 'AppData', 'Roaming'), 'orca-dev')
}
return path.join(process.env.XDG_CONFIG_HOME ?? path.join(process.env.HOME ?? '', '.config'), 'orca-dev')
}
function prepareDevCliWrapper() {
const binDir = path.join(repoRoot, 'out', 'bin')
mkdirSync(binDir, { recursive: true })
const userDataPath = getDevUserDataPath()
const cliPath = path.join(repoRoot, 'out', 'cli', 'index.js')
if (process.platform === 'win32') {
writeFileSync(
path.join(binDir, 'orca-dev.cmd'),
`@echo off\r\nset "ORCA_USER_DATA_PATH=${userDataPath}"\r\nnode "${cliPath}" %*\r\n`,
'utf8'
)
} else {
const wrapperPath = path.join(binDir, 'orca-dev')
writeFileSync(
wrapperPath,
`#!/usr/bin/env bash\nexport ORCA_USER_DATA_PATH=${JSON.stringify(userDataPath)}\nexec node ${JSON.stringify(cliPath)} "$@"\n`,
'utf8'
)
chmodSync(wrapperPath, 0o755)
}
process.env.PATH = `${binDir}${path.delimiter}${process.env.PATH ?? ''}`
console.log(`[orca-dev] Prepared wrapper in ${binDir}`)
}
if (process.env.ORCA_SKIP_DEV_CLI_PREPARE !== '1') {
prepareDevCliWrapper()
}
// Why: tests inject a tiny fake CLI here so they can verify Ctrl+C tears down
// the full child tree without depending on a real electron-vite install.
const electronViteCli =
@ -17,6 +64,7 @@ const electronViteCli =
const forwardedArgs = ['dev', ...process.argv.slice(2)]
const child = spawn(process.execPath, [electronViteCli, ...forwardedArgs], {
stdio: 'inherit',
env: process.env,
// Why: electron-vite launches Electron as a descendant process. Giving the
// dev runner its own process group lets Ctrl+C kill the whole tree on macOS
// instead of leaving the Electron app alive after the terminal exits.

View File

@ -5,7 +5,8 @@
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
"bin": {
"orca": "./out/cli/index.js"
"orca": "./out/cli/index.js",
"orca-dev": "./bin/orca-dev"
},
"main": "./out/main/index.js",
"scripts": {
@ -24,11 +25,12 @@
"start": "electron-vite preview",
"dev": "node config/scripts/run-electron-vite-dev.mjs",
"build:relay": "node scripts/build-relay.mjs",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false",
"build:cli": "tsc -p config/tsconfig.cli.json --outDir out --composite false --incremental false && node config/scripts/install-dev-cli.mjs",
"build:electron-vite": "node config/scripts/run-electron-vite-build.mjs",
"build": "pnpm run typecheck && pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli",
"build:release": "pnpm run build:relay && pnpm run build:electron-vite && pnpm run build:cli",
"postinstall": "pnpm rebuild electron && node scripts/rebuild-native-deps.mjs",
"rebuild:electron": "node scripts/rebuild-native-deps.mjs",
"build:unpack": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --dir",
"build:win": "pnpm run build && electron-builder --config config/electron-builder.config.cjs --win",
"build:icons": "bash resources/icon-source/generate.sh",
@ -72,6 +74,7 @@
"@xterm/headless": "6.1.0-beta.198",
"@xterm/xterm": "6.1.0-beta.198",
"agent-browser": "~0.24.1",
"better-sqlite3": "^12.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -114,6 +117,7 @@
"@playwright/test": "^1.59.1",
"@stablyai/playwright-test": "^2.1.13",
"@tailwindcss/vite": "^4.2.2",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^25.5.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@ -149,6 +153,7 @@
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"better-sqlite3",
"cpu-features",
"electron",
"esbuild",

View File

@ -115,6 +115,9 @@ importers:
agent-browser:
specifier: ~0.24.1
version: 0.24.1
better-sqlite3:
specifier: ^12.9.0
version: 12.9.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -236,6 +239,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.2.2
version: 4.2.2(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))
'@types/better-sqlite3':
specifier: ^7.6.13
version: 7.6.13
'@types/node':
specifier: ^25.5.0
version: 25.5.0
@ -2672,6 +2678,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
@ -3126,6 +3135,13 @@ packages:
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
better-sqlite3@12.9.0:
resolution: {integrity: sha512-wqUv4Gm3toFpHDQmaKD4QhZm3g1DjUBI0yzS4UBl6lElUmXFYdTQmmEDpAFa5o8FiFiymURypEnfVHzILKaxqQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x}
bindings@1.5.0:
resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==}
bippy@0.5.32:
resolution: {integrity: sha512-yt1mC8eReTxjfg41YBZdN4PvsDwHFWxltoiQX0Q+Htlbf41aSniopb7ECZits01HwNAvXEh69RGk/ImlswDTEw==}
peerDependencies:
@ -3256,6 +3272,9 @@ packages:
resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==}
engines: {node: '>=22.0.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@ -3627,6 +3646,10 @@ packages:
babel-plugin-macros:
optional: true
deep-extend@0.6.0:
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
engines: {node: '>=4.0.0'}
deepmerge@4.3.1:
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
engines: {node: '>=0.10.0'}
@ -3898,6 +3921,10 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
expand-template@2.0.3:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
@ -3970,6 +3997,9 @@ packages:
resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==}
engines: {node: '>=18'}
file-uri-to-path@1.0.0:
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
filelist@1.0.6:
resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==}
@ -4005,6 +4035,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@ -4088,6 +4121,9 @@ packages:
resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==}
engines: {node: '>=18'}
github-from-package@0.0.0:
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
github-slugger@2.0.0:
resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==}
@ -4293,6 +4329,9 @@ packages:
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
ini@1.3.8:
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@ -4950,6 +4989,9 @@ packages:
resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==}
engines: {node: '>= 18'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
@ -4985,10 +5027,17 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
napi-build-utils@2.0.0:
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
node-abi@3.89.0:
resolution: {integrity: sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==}
engines: {node: '>=10'}
node-abi@4.28.0:
resolution: {integrity: sha512-Qfp5XZL1cJDOabOT8H5gnqMTmM4NjvYzHp4I/Kt/Sl76OVkOBBHRFlPspGV0hYvMoqQsypFjT/Yp7Km0beXW9g==}
engines: {node: '>=22.12.0'}
@ -5258,6 +5307,12 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
@ -5363,6 +5418,10 @@ packages:
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
engines: {node: '>= 0.10'}
rc@1.2.8:
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
hasBin: true
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
@ -5649,6 +5708,12 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-concat@1.0.1:
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
simple-get@4.0.1:
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
simple-git@3.33.0:
resolution: {integrity: sha512-D4V/tGC2sjsoNhoMybKyGoE+v8A60hRawKQ1iFRA1zwuDgGZCBJ4ByOzZ5J8joBbi4Oam0qiPH+GhzmSBwbJng==}
@ -5795,6 +5860,10 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@ -5826,6 +5895,13 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
tar@7.5.12:
resolution: {integrity: sha512-9TsuLcdhOn4XztcQqhNyq1KOwOOED/3k58JAvtULiYqbO8B/0IBAAIE1hj0Svmm58k27TmcigyDI0deMlgG3uw==}
engines: {node: '>=18'}
@ -5917,6 +5993,9 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tunnel-agent@0.6.0:
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
tw-animate-css@1.4.0:
resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==}
@ -8484,6 +8563,10 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/better-sqlite3@7.6.13':
dependencies:
'@types/node': 25.5.0
'@types/cacheable-request@6.0.3':
dependencies:
'@types/http-cache-semantics': 4.2.0
@ -8980,6 +9063,15 @@ snapshots:
dependencies:
tweetnacl: 0.14.5
better-sqlite3@12.9.0:
dependencies:
bindings: 1.5.0
prebuild-install: 7.1.3
bindings@1.5.0:
dependencies:
file-uri-to-path: 1.0.0
bippy@0.5.32(@types/react@19.2.14)(react@19.2.4):
dependencies:
'@types/react-reconciler': 0.28.9(@types/react@19.2.14)
@ -9156,6 +9248,8 @@ snapshots:
'@chevrotain/types': 12.0.0
'@chevrotain/utils': 12.0.0
chownr@1.1.4: {}
chownr@3.0.0: {}
chromium-pickle-js@0.2.0: {}
@ -9515,6 +9609,8 @@ snapshots:
dedent@1.7.2: {}
deep-extend@0.6.0: {}
deepmerge@4.3.1: {}
default-browser-id@5.0.1: {}
@ -9887,6 +9983,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
expand-template@2.0.3: {}
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {}
@ -9985,6 +10083,8 @@ snapshots:
dependencies:
is-unicode-supported: 2.1.0
file-uri-to-path@1.0.0: {}
filelist@1.0.6:
dependencies:
minimatch: 5.1.9
@ -10027,6 +10127,8 @@ snapshots:
fresh@2.0.0: {}
fs-constants@1.0.0: {}
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@ -10113,6 +10215,8 @@ snapshots:
'@sec-ant/readable-stream': 0.4.1
is-stream: 4.0.1
github-from-package@0.0.0: {}
github-slugger@2.0.0: {}
glob-parent@5.1.2:
@ -10401,6 +10505,8 @@ snapshots:
inherits@2.0.4: {}
ini@1.3.8: {}
inline-style-parser@0.2.7: {}
internmap@1.0.1: {}
@ -11224,6 +11330,8 @@ snapshots:
dependencies:
minipass: 7.1.3
mkdirp-classic@0.5.3: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
@ -11274,8 +11382,14 @@ snapshots:
nanoid@3.3.11: {}
napi-build-utils@2.0.0: {}
negotiator@1.0.0: {}
node-abi@3.89.0:
dependencies:
semver: 7.7.4
node-abi@4.28.0:
dependencies:
semver: 7.7.4
@ -11581,6 +11695,21 @@ snapshots:
powershell-utils@0.1.0: {}
prebuild-install@7.1.3:
dependencies:
detect-libc: 2.1.2
expand-template: 2.0.3
github-from-package: 0.0.0
minimist: 1.2.8
mkdirp-classic: 0.5.3
napi-build-utils: 2.0.0
node-abi: 3.89.0
pump: 3.0.4
rc: 1.2.8
simple-get: 4.0.1
tar-fs: 2.1.4
tunnel-agent: 0.6.0
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
@ -11768,6 +11897,13 @@ snapshots:
iconv-lite: 0.7.2
unpipe: 1.0.0
rc@1.2.8:
dependencies:
deep-extend: 0.6.0
ini: 1.3.8
minimist: 1.2.8
strip-json-comments: 2.0.1
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4
@ -12200,6 +12336,14 @@ snapshots:
signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
simple-get@4.0.1:
dependencies:
decompress-response: 6.0.0
once: 1.4.0
simple-concat: 1.0.1
simple-git@3.33.0:
dependencies:
'@kwsites/file-exists': 1.1.1
@ -12353,6 +12497,8 @@ snapshots:
strip-final-newline@4.0.0: {}
strip-json-comments@2.0.1: {}
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@ -12381,6 +12527,21 @@ snapshots:
tapable@2.3.0: {}
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.4
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
tar@7.5.12:
dependencies:
'@isaacs/fs-minipass': 4.0.1
@ -12467,6 +12628,10 @@ snapshots:
tslib@2.8.1: {}
tunnel-agent@0.6.0:
dependencies:
safe-buffer: 5.2.1
tw-animate-css@1.4.0: {}
tweetnacl@0.14.5: {}

View File

@ -34,11 +34,25 @@ if (ignoreModules.length > 0) {
console.log(`[rebuild] Skipping modules on Windows: ${ignoreModules.join(', ')}`)
}
// Why: @electron/rebuild's default module walker doesn't reliably find native
// modules inside pnpm's .pnpm/ store. Passing an explicit list of modules to
// rebuild via `onlyModules` ensures they're recompiled against Electron's Node
// ABI regardless of the package manager's store layout.
const NATIVE_MODULES = ['better-sqlite3', 'node-pty', 'cpu-features']
const onlyModules = NATIVE_MODULES.filter((m) => !ignoreModules.includes(m))
try {
await rebuild({
buildPath: projectDir,
electronVersion,
ignoreModules,
onlyModules,
// Why: without force, @electron/rebuild skips modules it considers
// "already built" — even when they were compiled for the wrong ABI
// (e.g., system Node instead of Electron's embedded Node). This is
// common after pnpm install, which compiles native modules for system
// Node before postinstall runs this script.
force: true,
})
} catch (/** @type {any} */ err) {
console.error('[rebuild] Native module rebuild failed:', err?.message ?? err)

View File

@ -16,7 +16,6 @@ Use `orca` for:
- reading and replying to Orca-managed terminals
- stopping or waiting on Orca-managed terminals
- accessing repos known to Orca
Do not use `orca` when plain shell tools are simpler and Orca state does not matter.
Examples:
@ -179,7 +178,7 @@ Why: `--direction horizontal` splits the pane **left and right** (new pane appea
- Use `terminal read` before `terminal send` unless the next input is obvious.
- Use `terminal wait --terminal <handle> --for exit` only when the task actually depends on process completion.
- Use `terminal wait --terminal <handle> --for tui-idle` to wait for an agent CLI (Claude Code, Gemini, Codex, etc.) to finish its current task. This detects the working→idle OSC title transition. Always pass `--timeout-ms` as a safety net — unsupported CLIs will hang until timeout.
- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup and `--title` for labeling.
- Use `terminal create` to spin up new terminal tabs programmatically, optionally with a `--command` for startup (e.g. `--command "claude"` to launch Claude Code) and `--title` for labeling. After creating a `--command` terminal, use `terminal wait --for tui-idle` to wait for the agent to boot before dispatching.
- Use `terminal split` to create split panes within an existing terminal tab. Pass `--command` to run a command in the new pane.
- Prefer Orca worktree selectors over hardcoded paths when Orca identity already exists.
- If the user asks for CLI UX feedback, test the public `orca` command first. Only inspect `src/cli` or use `node out/cli/index.js` if the public command is missing or the task is explicitly about implementation internals.
@ -556,10 +555,11 @@ When `orca tab create` opens a new tab, it is automatically set as the active ta
## Important Constraints
- Orca CLI only talks to a running Orca editor.
- Terminal handles are ephemeral and tied to the current Orca runtime.
- `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified.
- Orca is the source of truth for worktree/terminal orchestration; do not duplicate that state with manual assumptions.
- Terminal handles are ephemeral and tied to the current Orca runtime. If Orca restarts, handles change.
- `terminal wait` supports `--for exit` (wait for process exit) and `--for tui-idle` (wait for a recognized agent CLI like Claude Code, Gemini, or Codex to finish its current task, detected via OSC title transitions). `tui-idle` defaults to a 5-minute timeout if `--timeout-ms` is not specified. Real coding tasks routinely take 15-60 minutes — always pass `--timeout-ms` explicitly.
- Orca is the source of truth for worktree/terminal state; do not duplicate that state with manual assumptions.
- The public `orca` command is the interface users experience. Agents should validate and use that surface, not repo-local implementation entrypoints.
- The 120-line terminal output buffer (`terminal read`) is for status monitoring, not result extraction.
## References

View File

@ -82,7 +82,8 @@ export function isCommandGroup(commandPath: string[]): boolean {
'set',
'clipboard',
'dialog',
'storage'
'storage',
'orchestration'
].includes(commandPath[0])) ||
(commandPath.length === 2 &&
commandPath[0] === 'storage' &&

View File

@ -11,6 +11,7 @@ import { BROWSER_COOKIE_HANDLERS } from './handlers/browser-cookie'
import { BROWSER_CAPTURE_HANDLERS } from './handlers/browser-capture'
import { BROWSER_ENV_HANDLERS } from './handlers/browser-env'
import { BROWSER_STORAGE_HANDLERS } from './handlers/browser-storage'
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
export type HandlerContext = {
flags: Map<string, string | boolean>
@ -34,7 +35,8 @@ function buildHandlers(): Map<string, CommandHandler> {
BROWSER_COOKIE_HANDLERS,
BROWSER_CAPTURE_HANDLERS,
BROWSER_ENV_HANDLERS,
BROWSER_STORAGE_HANDLERS
BROWSER_STORAGE_HANDLERS,
ORCHESTRATION_HANDLERS
]
for (const group of groups) {
for (const [key, handler] of Object.entries(group)) {

View File

@ -0,0 +1,263 @@
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import {
getOptionalPositiveIntegerFlag,
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { getTerminalHandle } from '../selectors'
type MessageSummary = {
id: string
from_handle: string
to_handle?: string
subject: string
type?: string
}
async function resolveOrchestrationTerminalHandle(
flags: Map<string, string | boolean>,
cwd: string,
client: Parameters<CommandHandler>[0]['client'],
flagName: 'from' | 'terminal'
): Promise<string> {
const explicit = getOptionalStringFlag(flags, flagName)
if (explicit) {
return explicit
}
const envHandle = process.env.ORCA_TERMINAL_HANDLE
if (envHandle && envHandle.length > 0) {
return envHandle
}
return await getTerminalHandle(flags, cwd, client)
}
function isDevCliInvocation(): boolean {
return process.env.ORCA_USER_DATA_PATH?.includes('orca-dev') ?? false
}
export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
'orchestration send': async ({ flags, client, cwd, json }) => {
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call<
{ message: { id: string } } | { messages: { id: string }[]; recipients: number }
>('orchestration.send', {
from,
to: getRequiredStringFlag(flags, 'to'),
subject: getRequiredStringFlag(flags, 'subject'),
body: getOptionalStringFlag(flags, 'body'),
type: getOptionalStringFlag(flags, 'type'),
priority: getOptionalStringFlag(flags, 'priority'),
threadId: getOptionalStringFlag(flags, 'thread-id'),
payload: getOptionalStringFlag(flags, 'payload'),
devMode: isDevCliInvocation()
})
printResult(result, json, (r) => {
if ('message' in r) {
return `Sent ${r.message.id}`
}
return `Sent ${r.messages.length} messages to ${r.recipients} recipients`
})
},
'orchestration check': async ({ flags, client, cwd, json }) => {
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
const result = await client.call<{
messages: MessageSummary[]
count: number
formatted?: string
}>('orchestration.check', {
terminal,
unread: flags.has('unread') ? true : undefined,
types: getOptionalStringFlag(flags, 'types'),
inject: flags.has('inject') ? true : undefined,
wait: flags.has('wait') ? true : undefined,
timeoutMs: flags.has('timeout-ms') ? Number(flags.get('timeout-ms')) : undefined
})
printResult(result, json, (r) => {
if (r.formatted) {
return r.formatted
}
if (r.count === 0) {
return 'No messages.'
}
return r.messages
.map((m) => `${m.id} [${m.type ?? 'status'}] from=${m.from_handle} "${m.subject}"`)
.join('\n')
})
},
'orchestration reply': async ({ flags, client, cwd, json }) => {
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call<{ message: { id: string } }>('orchestration.reply', {
id: getRequiredStringFlag(flags, 'id'),
body: getRequiredStringFlag(flags, 'body'),
from
})
printResult(result, json, (r) => `Replied ${r.message.id}`)
},
'orchestration inbox': async ({ flags, client, json }) => {
const result = await client.call<{
messages: MessageSummary[]
count: number
}>('orchestration.inbox', {
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
})
printResult(result, json, (r) => {
if (r.count === 0) {
return 'No messages.'
}
return r.messages
.map((m) => `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`)
.join('\n')
})
},
'orchestration task-create': async ({ flags, client, json }) => {
const result = await client.call<{ task: { id: string; status: string } }>(
'orchestration.taskCreate',
{
spec: getRequiredStringFlag(flags, 'spec'),
deps: getOptionalStringFlag(flags, 'deps'),
parent: getOptionalStringFlag(flags, 'parent')
}
)
printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`)
},
'orchestration task-list': async ({ flags, client, json }) => {
const result = await client.call<{
tasks: { id: string; spec: string; status: string }[]
count: number
}>('orchestration.taskList', {
status: getOptionalStringFlag(flags, 'status'),
ready: flags.has('ready') ? true : undefined
})
printResult(result, json, (r) => {
if (r.count === 0) {
return 'No tasks.'
}
return r.tasks.map((t) => `${t.id} [${t.status}] ${t.spec.slice(0, 60)}`).join('\n')
})
},
'orchestration task-update': async ({ flags, client, json }) => {
const result = await client.call<{ task: { id: string; status: string } }>(
'orchestration.taskUpdate',
{
id: getRequiredStringFlag(flags, 'id'),
status: getRequiredStringFlag(flags, 'status'),
result: getOptionalStringFlag(flags, 'result')
}
)
printResult(result, json, (r) => `Updated ${r.task.id} -> ${r.task.status}`)
},
'orchestration dispatch': async ({ flags, client, cwd, json }) => {
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call<{
dispatch: { id: string; task_id: string; status: string }
}>('orchestration.dispatch', {
task: getRequiredStringFlag(flags, 'task'),
to: getRequiredStringFlag(flags, 'to'),
from,
inject: flags.has('inject') ? true : undefined,
devMode: isDevCliInvocation()
})
printResult(
result,
json,
(r) => `Dispatched ${r.dispatch.task_id} -> ${r.dispatch.id} [${r.dispatch.status}]`
)
},
'orchestration dispatch-show': async ({ flags, client, json }) => {
const result = await client.call<{
dispatch: { id: string; task_id: string; status: string } | null
}>('orchestration.dispatchShow', {
task: getRequiredStringFlag(flags, 'task')
})
printResult(result, json, (r) => {
if (!r.dispatch) {
return 'No dispatch context found.'
}
return `${r.dispatch.id} task=${r.dispatch.task_id} [${r.dispatch.status}]`
})
},
'orchestration run': async ({ flags, client, cwd, json }) => {
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call<{
runId: string
status: string
}>('orchestration.run', {
spec: getRequiredStringFlag(flags, 'spec'),
from,
pollIntervalMs: getOptionalPositiveIntegerFlag(flags, 'poll-interval-ms'),
maxConcurrent: getOptionalPositiveIntegerFlag(flags, 'max-concurrent'),
worktree: getOptionalStringFlag(flags, 'worktree')
})
printResult(result, json, (r) => `Run ${r.runId} started (${r.status})`)
},
'orchestration run-stop': async ({ client, json }) => {
const result = await client.call<{
runId: string
stopped: boolean
}>('orchestration.runStop', {})
printResult(result, json, (r) => `Run ${r.runId} stopped`)
},
'orchestration gate-create': async ({ flags, client, json }) => {
const result = await client.call<{
gate: { id: string; task_id: string; status: string }
}>('orchestration.gateCreate', {
task: getRequiredStringFlag(flags, 'task'),
question: getRequiredStringFlag(flags, 'question'),
options: getOptionalStringFlag(flags, 'options')
})
printResult(
result,
json,
(r) => `Gate ${r.gate.id} created for task ${r.gate.task_id} [${r.gate.status}]`
)
},
'orchestration gate-resolve': async ({ flags, client, json }) => {
const result = await client.call<{
gate: { id: string; task_id: string; status: string; resolution: string }
}>('orchestration.gateResolve', {
id: getRequiredStringFlag(flags, 'id'),
resolution: getRequiredStringFlag(flags, 'resolution')
})
printResult(result, json, (r) => `Gate ${r.gate.id} resolved: ${r.gate.resolution}`)
},
'orchestration gate-list': async ({ flags, client, json }) => {
const result = await client.call<{
gates: { id: string; task_id: string; question: string; status: string }[]
count: number
}>('orchestration.gateList', {
task: getOptionalStringFlag(flags, 'task'),
status: getOptionalStringFlag(flags, 'status')
})
printResult(result, json, (r) => {
if (r.gates.length === 0) {
return 'No gates found.'
}
return r.gates
.map((g) => `${g.id} task=${g.task_id} [${g.status}] "${g.question}"`)
.join('\n')
})
},
'orchestration reset': async ({ flags, client, json }) => {
const result = await client.call<{ reset: string }>('orchestration.reset', {
all: flags.has('all') ? true : undefined,
tasks: flags.has('tasks') ? true : undefined,
messages: flags.has('messages') ? true : undefined
})
printResult(result, json, (r) => `Reset: ${r.reset}`)
}
}

View File

@ -39,6 +39,23 @@ Terminals:
terminal focus Alias for terminal switch
terminal close Close a terminal pane (or tab if last pane)
Orchestration:
orchestration send Send an inter-agent message
orchestration check Check messages for a terminal
orchestration reply Reply to a message
orchestration inbox Show all messages across recipients
orchestration task-create Create an orchestration task
orchestration task-list List orchestration tasks
orchestration task-update Update a task status
orchestration dispatch Dispatch a task to a terminal
orchestration dispatch-show Show dispatch context for a task
orchestration run Start the coordinator loop
orchestration run-stop Stop the active coordinator run
orchestration gate-create Create a decision gate blocking a task
orchestration gate-resolve Resolve a pending decision gate
orchestration gate-list List decision gates
orchestration reset Reset orchestration state
Browser Automation:
tab create Create a new browser tab (navigates to --url)
tab list List open browser tabs

View File

@ -55,12 +55,25 @@ describe('COMMAND_SPECS collision check', () => {
})
describe('orca cli worktree awareness', () => {
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
const originalUserDataPath = process.env.ORCA_USER_DATA_PATH
beforeEach(() => {
callMock.mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
if (originalTerminalHandle === undefined) {
delete process.env.ORCA_TERMINAL_HANDLE
} else {
process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle
}
if (originalUserDataPath === undefined) {
delete process.env.ORCA_USER_DATA_PATH
} else {
process.env.ORCA_USER_DATA_PATH = originalUserDataPath
}
})
it('builds the current worktree selector from cwd', () => {
@ -153,6 +166,66 @@ describe('orca cli worktree awareness', () => {
})
})
it('formats group orchestration sends in text mode', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
callMock.mockResolvedValueOnce({
id: 'req_send',
ok: true,
result: {
messages: [{ id: 'msg_1' }, { id: 'msg_2' }],
recipients: 2
},
_meta: {
runtimeId: 'runtime-1'
}
})
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
await main(['orchestration', 'send', '--to', '@all', '--subject', 'hello'], '/tmp/repo')
expect(callMock).toHaveBeenCalledWith('orchestration.send', {
from: 'term_sender',
to: '@all',
subject: 'hello',
body: undefined,
type: undefined,
priority: undefined,
threadId: undefined,
payload: undefined,
devMode: false
})
expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients')
})
it('passes dev mode to injected orchestration dispatches', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev'
callMock.mockResolvedValueOnce({
id: 'req_dispatch',
ok: true,
result: {
dispatch: { id: 'ctx_1', task_id: 'task_1', status: 'dispatched' }
},
_meta: {
runtimeId: 'runtime-1'
}
})
vi.spyOn(console, 'log').mockImplementation(() => {})
await main(
['orchestration', 'dispatch', '--task', 'task_1', '--to', 'term_worker', '--inject'],
'/tmp/repo'
)
expect(callMock).toHaveBeenCalledWith('orchestration.dispatch', {
task: 'task_1',
to: 'term_worker',
from: 'term_sender',
inject: true,
devMode: true
})
})
it('uses the resolved enclosing worktree for terminal consumers', async () => {
queueFixtures(
callMock,

View File

@ -2,9 +2,11 @@ import type { CommandSpec } from '../args'
import { BROWSER_ADVANCED_COMMAND_SPECS } from './browser-advanced'
import { BROWSER_BASIC_COMMAND_SPECS } from './browser-basic'
import { CORE_COMMAND_SPECS } from './core'
import { ORCHESTRATION_COMMAND_SPECS } from './orchestration'
export const COMMAND_SPECS: CommandSpec[] = [
...CORE_COMMAND_SPECS,
...BROWSER_BASIC_COMMAND_SPECS,
...BROWSER_ADVANCED_COMMAND_SPECS
...BROWSER_ADVANCED_COMMAND_SPECS,
...ORCHESTRATION_COMMAND_SPECS
]

View File

@ -0,0 +1,119 @@
import type { CommandSpec } from '../args'
import { GLOBAL_FLAGS } from '../args'
export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'send'],
summary: 'Send an inter-agent message',
usage:
'orca orchestration send --to <handle> --subject <text> [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'to',
'from',
'subject',
'body',
'type',
'priority',
'thread-id',
'payload'
]
},
{
path: ['orchestration', 'check'],
summary: 'Check messages for a terminal',
usage:
'orca orchestration check [--terminal <handle>] [--unread] [--types <type,...>] [--inject] [--wait] [--timeout-ms <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'unread', 'types', 'inject', 'wait', 'timeout-ms']
},
{
path: ['orchestration', 'reply'],
summary: 'Reply to a message',
usage: 'orca orchestration reply --id <msg_id> --body <text> [--from <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'from']
},
{
path: ['orchestration', 'inbox'],
summary: 'Show all messages across recipients',
usage: 'orca orchestration inbox [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit']
},
{
path: ['orchestration', 'task-create'],
summary: 'Create an orchestration task',
usage:
'orca orchestration task-create --spec <text> [--deps <json_array>] [--parent <task_id>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'spec', 'deps', 'parent']
},
{
path: ['orchestration', 'task-list'],
summary: 'List orchestration tasks',
usage: 'orca orchestration task-list [--status <status>] [--ready] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready']
},
{
path: ['orchestration', 'task-update'],
summary: 'Update a task status',
usage:
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result']
},
{
path: ['orchestration', 'dispatch'],
summary: 'Dispatch a task to a terminal',
usage:
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--inject] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject']
},
{
path: ['orchestration', 'dispatch-show'],
summary: 'Show dispatch context for a task',
usage: 'orca orchestration dispatch-show --task <task_id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task']
},
{
path: ['orchestration', 'run'],
summary: 'Start the coordinator loop',
usage:
'orca orchestration run --spec <text> [--from <handle>] [--poll-interval-ms <n>] [--max-concurrent <n>] [--worktree <selector>] [--json]',
allowedFlags: [
...GLOBAL_FLAGS,
'spec',
'from',
'poll-interval-ms',
'max-concurrent',
'worktree'
]
},
{
path: ['orchestration', 'run-stop'],
summary: 'Stop the active coordinator run',
usage: 'orca orchestration run-stop [--json]',
allowedFlags: [...GLOBAL_FLAGS]
},
{
path: ['orchestration', 'gate-create'],
summary: 'Create a decision gate blocking a task',
usage:
'orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options']
},
{
path: ['orchestration', 'gate-resolve'],
summary: 'Resolve a pending decision gate',
usage: 'orca orchestration gate-resolve --id <gate_id> --resolution <text> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution']
},
{
path: ['orchestration', 'gate-list'],
summary: 'List decision gates',
usage: 'orca orchestration gate-list [--task <task_id>] [--status <status>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'status']
},
{
path: ['orchestration', 'reset'],
summary: 'Reset orchestration state',
usage: 'orca orchestration reset [--all] [--tasks] [--messages] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'all', 'tasks', 'messages']
}
]

View File

@ -801,6 +801,57 @@ describe('registerPtyHandlers', () => {
expect(sshShutdown).toHaveBeenCalledWith('remote-pty', true)
})
it('injects ORCA_TERMINAL_HANDLE for non-local PTY providers', async () => {
const spawn = vi.fn(async () => ({ id: 'remote-pty' }))
registerSshPtyProvider('ssh-1', {
spawn,
write: vi.fn(),
resize: vi.fn(),
shutdown: vi.fn(),
sendSignal: vi.fn(),
getCwd: vi.fn(),
getInitialCwd: vi.fn(),
clearBuffer: vi.fn(),
onData: vi.fn(() => () => {}),
onReplay: vi.fn(() => () => {}),
onExit: vi.fn(() => () => {}),
listProcesses: vi.fn(),
hasChildProcesses: vi.fn(),
getForegroundProcess: vi.fn(),
serialize: vi.fn(),
revive: vi.fn(),
getDefaultShell: vi.fn(),
getProfiles: vi.fn(),
acknowledgeDataEvent: vi.fn()
} as never)
const runtime = {
setPtyController: vi.fn(),
createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'),
registerPreAllocatedHandleForPty: vi.fn()
}
registerPtyHandlers(mainWindow as never, runtime as never)
await handlers.get('pty:spawn')!(null, {
cols: 80,
rows: 24,
connectionId: 'ssh-1',
env: { EXISTING: '1' }
})
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
env: expect.objectContaining({
EXISTING: '1',
ORCA_TERMINAL_HANDLE: 'term_remote'
})
})
)
expect(runtime.registerPreAllocatedHandleForPty).toHaveBeenCalledWith(
'remote-pty',
'term_remote'
)
})
describe('Windows UTF-8 code page', () => {
let originalPlatform: string
let originalComspec: string | undefined
@ -1295,7 +1346,8 @@ describe('registerPtyHandlers', () => {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn()
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
spawnMock.mockReturnValue(proc)
@ -1333,7 +1385,8 @@ describe('registerPtyHandlers', () => {
setPtyController: vi.fn(),
onPtySpawned: vi.fn(),
onPtyData: vi.fn(),
onPtyExit: vi.fn()
onPtyExit: vi.fn(),
preAllocateHandleForPty: vi.fn()
}
spawnMock.mockReturnValue(proc)

View File

@ -188,6 +188,13 @@ export function buildPtyHostEnv(
// Orca's own PTYs. Injecting lightweight PATH shims at spawn-time keeps
// the behavior local to Orca instead of rewriting user git config or
// touching external shells.
if (!opts.githubAttributionEnabled) {
delete baseEnv.ORCA_ENABLE_GIT_ATTRIBUTION
delete baseEnv.ORCA_GIT_COMMIT_TRAILER
delete baseEnv.ORCA_GH_PR_FOOTER
delete baseEnv.ORCA_GH_ISSUE_FOOTER
delete baseEnv.ORCA_ATTRIBUTION_SHIM_DIR
}
applyTerminalAttributionEnv(baseEnv, {
enabled: opts.githubAttributionEnabled,
userDataPath: opts.userDataPath
@ -339,13 +346,21 @@ export function registerPtyHandlers(
localProvider.configure({
isHistoryEnabled: () => getSettings?.()?.terminalScopeHistoryByWorktree ?? true,
getWindowsShell: () => getSettings?.()?.terminalWindowsShell,
buildSpawnEnv: (id, baseEnv) =>
buildPtyHostEnv(id, baseEnv, {
buildSpawnEnv: (id, baseEnv) => {
const env = buildPtyHostEnv(id, baseEnv, {
isPackaged: app.isPackaged,
userDataPath: app.getPath('userData'),
selectedCodexHomePath: getSelectedCodexHomePath?.() ?? null,
githubAttributionEnabled: getSettings?.()?.enableGitHubAttribution ?? false
}),
})
// Why: agents need their own terminal handle at process start so they
// can self-identify in orchestration messages without an extra RPC.
const preAllocatedHandle = runtime?.preAllocateHandleForPty(id)
if (preAllocatedHandle) {
env.ORCA_TERMINAL_HANDLE = preAllocatedHandle
}
return env
},
onSpawned: (id) => runtime?.onPtySpawned(id),
onExit: (id, code) => {
clearProviderPtyState(id)
@ -380,6 +395,12 @@ export function registerPtyHandlers(
pendingData.clear()
}
// Why: LocalPtyProvider routes data to the runtime via configure().onData,
// but daemon-backed providers don't have configure(). Without this, daemon
// PTY data never reaches the runtime's tail buffer, so terminal.read returns
// empty and agent-detection from raw data never fires.
const isLocalProvider = localProvider instanceof LocalPtyProvider
localDataUnsub = localProvider.onData((payload) => {
if (mainWindow.isDestroyed()) {
// Why: clear the pending flush timer so it doesn't fire after the window
@ -392,6 +413,9 @@ export function registerPtyHandlers(
pendingData.clear()
return
}
if (!isLocalProvider) {
runtime?.onPtyData(payload.id, payload.data, Date.now())
}
const existing = pendingData.get(payload.id)
pendingData.set(payload.id, existing ? existing + payload.data : payload.data)
if (!flushTimer) {
@ -399,6 +423,12 @@ export function registerPtyHandlers(
}
})
localExitUnsub = localProvider.onExit((payload) => {
if (!isLocalProvider) {
clearProviderPtyState(payload.id)
ptyOwnership.delete(payload.id)
markClaudePtyExited(payload.id)
runtime?.onPtyExit(payload.id, payload.code)
}
if (!mainWindow.isDestroyed()) {
// Why: flush any batched data for this PTY before sending the exit event,
// otherwise the last ≤8ms of output is silently lost because the renderer
@ -456,6 +486,13 @@ export function registerPtyHandlers(
markClaudePtyExited(ptyId)
runtime?.onPtyExit(ptyId, -1)
return true
},
getForegroundProcess: async (ptyId) => {
try {
return await getProviderForPty(ptyId).getForegroundProcess(ptyId)
} catch {
return null
}
}
})
@ -528,6 +565,10 @@ export function registerPtyHandlers(
args.sessionId ?? (isDaemonHostSpawn ? mintPtySessionId(args.worktreeId) : undefined)
const baseEnv = claudeAuth ? { ...args.env, ...claudeAuth.envPatch } : args.env
let env: Record<string, string> | undefined = baseEnv
const preAllocatedHandle =
runtime && !(provider instanceof LocalPtyProvider)
? runtime.createPreAllocatedTerminalHandle()
: null
if (isDaemonHostSpawn) {
if (effectiveSessionId === undefined) {
// Should be unreachable: the expression above returns a string when
@ -569,6 +610,9 @@ export function registerPtyHandlers(
throw err
}
}
const spawnEnv = preAllocatedHandle
? { ...env, ORCA_TERMINAL_HANDLE: preAllocatedHandle }
: env
const envToDelete = claudeAuth?.stripAuthEnv
? [...CLAUDE_AUTH_ENV_VARS, 'ANTHROPIC_CUSTOM_HEADERS']
: undefined
@ -576,7 +620,7 @@ export function registerPtyHandlers(
cols: args.cols,
rows: args.rows,
cwd: args.cwd,
env
env: spawnEnv
}
if (envToDelete) {
spawnOptions.envToDelete = envToDelete
@ -625,6 +669,9 @@ export function registerPtyHandlers(
throw err
}
ptyOwnership.set(result.id, args.connectionId ?? null)
if (preAllocatedHandle) {
runtime?.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
}
if (isClaudeLaunch) {
markClaudePtySpawned(result.id)
}

View File

@ -109,6 +109,8 @@ vi.mock('./pty', () => ({
registerSshPtyProvider: vi.fn(),
unregisterSshPtyProvider: vi.fn(),
clearPtyOwnershipForConnection: vi.fn(),
clearProviderPtyState: vi.fn(),
deletePtyOwnership: vi.fn(),
getSshPtyProvider: vi.fn(),
getPtyIdsForConnection: vi.fn().mockReturnValue([])
}))
@ -268,6 +270,43 @@ describe('SSH IPC handlers', () => {
expect(mockConnectionManager.connect).toHaveBeenCalledWith(target)
})
it('forwards remote PTY events into the runtime', async () => {
const runtime = {
onPtyData: vi.fn(),
onPtyExit: vi.fn()
}
registerSshHandlers(mockStore, () => mockWindow as never, runtime as never)
const target: SshTarget = {
id: 'ssh-1',
label: 'Server',
host: 'example.com',
port: 22,
username: 'deploy'
}
mockSshStore.getTarget.mockReturnValue(target)
mockConnectionManager.connect.mockResolvedValue({})
mockConnectionManager.getState.mockReturnValue({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
await handlers.get('ssh:connect')!(null, { targetId: 'ssh-1' })
const onData = mockPtyProvider.onData.mock.calls[0]?.[0] as
| ((payload: { id: string; data: string }) => void)
| undefined
const onExit = mockPtyProvider.onExit.mock.calls[0]?.[0] as
| ((payload: { id: string; code: number }) => void)
| undefined
onData?.({ id: 'remote-pty', data: 'hello' })
onExit?.({ id: 'remote-pty', code: 7 })
expect(runtime.onPtyData).toHaveBeenCalledWith('remote-pty', 'hello', expect.any(Number))
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 7)
})
it('ssh:disconnect calls connection manager', async () => {
mockConnectionManager.disconnect.mockResolvedValue(undefined)

View File

@ -17,6 +17,7 @@ import type {
import { isAuthError } from '../ssh/ssh-connection-utils'
import { registerSshBrowseHandler } from './ssh-browse'
import { requestCredential, registerCredentialHandler } from './ssh-passphrase'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
let sshStore: SshConnectionStore | null = null
let connectionManager: SshConnectionManager | null = null
@ -146,7 +147,8 @@ async function restorePortForwards(
export function registerSshHandlers(
store: Store,
getMainWindow: () => BrowserWindow | null
getMainWindow: () => BrowserWindow | null,
runtime?: OrcaRuntimeService
): { connectionManager: SshConnectionManager; sshStore: SshConnectionStore } {
// Why: on macOS, app re-activation creates a new BrowserWindow and re-calls
// this function. ipcMain.handle() throws if a handler is already registered,
@ -297,6 +299,7 @@ export function registerSshHandlers(
getMainWindow,
store,
portForwardManager!,
runtime,
(tid, ports, _platform) => {
broadcastDetectedPorts(getMainWindow, tid, ports)
}

View File

@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeMeta } from '../../shared/types'
import { addWorktree, listWorktrees } from '../git/worktree'
import { createSetupRunnerScript, getEffectiveHooks, runHook } from '../hooks'
import { OrchestrationDb } from './orchestration/db'
import { OrcaRuntimeService } from './orca-runtime'
const {
@ -78,6 +79,31 @@ afterEach(() => {
invalidateAuthorizedRootsCacheMock.mockReset()
})
function syncSinglePty(runtime: OrcaRuntimeService, ptyId: string | null = 'pty-1'): void {
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
title: 'Codex',
activeLeafId: 'pane:1',
layout: null
}
],
leaves: [
{
tabId: 'tab-1',
worktreeId: TEST_WORKTREE_ID,
leafId: 'pane:1',
paneRuntimeId: 1,
ptyId,
paneTitle: null
}
]
})
}
const TEST_WINDOW_ID = 1
const TEST_REPO_ID = 'repo-1'
const TEST_REPO_PATH = '/tmp/repo'
@ -300,7 +326,8 @@ describe('OrcaRuntimeService', () => {
writes.push(data)
return true
},
kill: () => true
kill: () => true,
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
@ -344,7 +371,7 @@ describe('OrcaRuntimeService', () => {
handle: terminal.handle,
accepted: true
})
expect(writes).toEqual(['continue\r'])
expect(writes).toEqual(['continue', '\r'])
})
it('waits for terminal exit and resolves with the exit status', async () => {
@ -438,6 +465,136 @@ describe('OrcaRuntimeService', () => {
expect(thirdRead.nextCursor).toBe('2')
})
it('delivers pending orchestration messages to an already-idle agent', async () => {
vi.useFakeTimers()
const runtime = new OrcaRuntimeService(store)
const db = new OrchestrationDb(':memory:')
const write = vi.fn().mockReturnValue(true)
runtime.setOrchestrationDb(db)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
db.insertMessage({ from: 'term_sender', to: terminal.handle, subject: 'hello' })
runtime.deliverPendingMessagesForHandle(terminal.handle)
expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: hello'))
// Why: markAsRead is deferred until the 500ms delayed Enter is confirmed,
// so we must advance timers past the split-write delay.
await vi.advanceTimersByTimeAsync(500)
expect(write).toHaveBeenCalledWith('pty-1', '\r')
expect(db.getUnreadMessages(terminal.handle)).toHaveLength(0)
db.close()
vi.useRealTimers()
})
it('adopts preallocated ORCA_TERMINAL_HANDLE as a valid runtime handle', async () => {
const runtime = new OrcaRuntimeService(store)
const handle = runtime.preAllocateHandleForPty('pty-1')
syncSinglePty(runtime)
runtime.onPtyData('pty-1', 'ready\n', 100)
const read = await runtime.readTerminal(handle)
expect(read.handle).toBe(handle)
expect(read.tail).toEqual(['ready'])
})
it('keeps preallocated terminal handles valid across renderer reloads', async () => {
const runtime = new OrcaRuntimeService(store)
const handle = runtime.preAllocateHandleForPty('pty-1')
syncSinglePty(runtime)
runtime.markRendererReloading(1)
syncSinglePty(runtime, null)
runtime.onPtyData('pty-1', 'after reload\n', 100)
const read = await runtime.readTerminal(handle)
expect(read.tail).toEqual(['after reload'])
})
it('keeps preallocated terminal handles valid when a reload graph omits the live leaf', async () => {
const runtime = new OrcaRuntimeService(store)
const handle = runtime.preAllocateHandleForPty('pty-1')
syncSinglePty(runtime)
runtime.markRendererReloading(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: []
})
runtime.onPtyData('pty-1', 'after omitted leaf\n', 100)
const read = await runtime.readTerminal(handle)
expect(read.tail).toEqual(['after omitted leaf'])
})
it('keeps preallocated terminal handles valid after graph unavailable during reload', async () => {
const runtime = new OrcaRuntimeService(store)
const handle = runtime.preAllocateHandleForPty('pty-1')
syncSinglePty(runtime)
runtime.markGraphUnavailable(1)
runtime.attachWindow(1)
runtime.syncWindowGraph(1, {
tabs: [],
leaves: []
})
runtime.onPtyData('pty-1', 'after unavailable\n', 100)
const read = await runtime.readTerminal(handle)
expect(read.tail).toEqual(['after unavailable'])
})
it('keeps already-idle status after tui-idle wait for immediate message delivery', async () => {
const runtime = new OrcaRuntimeService(store)
const db = new OrchestrationDb(':memory:')
const write = vi.fn().mockReturnValue(true)
runtime.setOrchestrationDb(db)
runtime.setPtyController({
write,
kill: vi.fn(),
getForegroundProcess: async () => null
})
syncSinglePty(runtime)
const [terminal] = (await runtime.listTerminals()).terminals
runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100)
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
await runtime.waitForTerminal(terminal.handle, { condition: 'tui-idle' })
db.insertMessage({ from: 'sender', to: terminal.handle, subject: 'after wait' })
runtime.deliverPendingMessagesForHandle(terminal.handle)
expect(write).toHaveBeenCalledWith('pty-1', expect.stringContaining('Subject: after wait'))
db.close()
})
it('resolves message waiters when notifyMessageArrived is called', async () => {
const runtime = new OrcaRuntimeService(store)
const waitPromise = runtime.waitForMessage('term_abc', { timeoutMs: 5000 })
runtime.notifyMessageArrived('term_abc')
await waitPromise
})
it('resolves message waiters on timeout when no message arrives', async () => {
const runtime = new OrcaRuntimeService(store)
const start = Date.now()
await runtime.waitForMessage('term_abc', { timeoutMs: 100 })
const elapsed = Date.now() - start
expect(elapsed).toBeGreaterThanOrEqual(90)
expect(elapsed).toBeLessThan(500)
})
it('fails terminal waits closed when the handle goes stale during reload', async () => {
const runtime = new OrcaRuntimeService(store)
@ -481,7 +638,7 @@ describe('OrcaRuntimeService', () => {
{
tabId: 'tab-1',
worktreeId: 'repo-1::/tmp/worktree-a',
title: 'Claude',
title: 'Terminal 1',
activeLeafId: 'pane:1',
layout: null
}
@ -611,7 +768,8 @@ describe('OrcaRuntimeService', () => {
kill: () => {
killed = true
return true
}
},
getForegroundProcess: async () => null
})
runtime.attachWindow(1)
@ -691,7 +849,8 @@ describe('OrcaRuntimeService', () => {
kill: () => {
killed = true
return true
}
},
getForegroundProcess: async () => null
})
runtime.attachWindow(1)

View File

@ -1,13 +1,19 @@
/* eslint-disable max-lines -- Why: the Orca runtime is the authoritative live control plane for the CLI, so handle validation, selector resolution, wait state, and summaries are kept together to avoid split-brain behavior. */
/* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */
/* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */
import { extractLastOscTitle, detectAgentStatusFromTitle } from '../../shared/agent-detection'
import {
extractLastOscTitle,
detectAgentStatusFromTitle,
isShellProcess
} from '../../shared/agent-detection'
import type { AgentStatus } from '../../shared/agent-detection'
import { gitExecFileAsync, gitExecFileSync } from '../git/runner'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
import { randomUUID } from 'crypto'
import { join } from 'path'
import { rm } from 'fs/promises'
import { OrchestrationDb } from './orchestration/db'
import { formatMessagesForInjection } from './orchestration/formatter'
import type { CreateWorktreeResult, Repo } from '../../shared/types'
import { isFolderRepo } from '../../shared/repo-kind'
import type {
@ -135,6 +141,7 @@ type RuntimeLeafRecord = RuntimeSyncedLeaf & {
type RuntimePtyController = {
write(ptyId: string, data: string): boolean
kill(ptyId: string): boolean
getForegroundProcess(ptyId: string): Promise<string | null>
}
type RuntimeNotifier = {
@ -169,6 +176,14 @@ type TerminalWaiter = {
resolve: (result: RuntimeTerminalWait) => void
reject: (error: Error) => void
timeout: NodeJS.Timeout | null
pollInterval: NodeJS.Timeout | null
}
type MessageWaiter = {
handle: string
typeFilter: string[] | undefined
resolve: (result: void) => void
timeout: NodeJS.Timeout | null
}
type ResolvedWorktree = {
@ -214,6 +229,8 @@ export class OrcaRuntimeService {
private leaves = new Map<string, RuntimeLeafRecord>()
private handles = new Map<string, TerminalHandleRecord>()
private handleByLeafKey = new Map<string, string>()
private handleByPtyId = new Map<string, string>()
private detachedPreAllocatedLeaves = new Map<string, RuntimeLeafRecord>()
private graphSyncCallbacks: (() => void)[] = []
private waitersByHandle = new Map<string, Set<TerminalWaiter>>()
private ptyController: RuntimePtyController | null = null
@ -221,6 +238,8 @@ export class OrcaRuntimeService {
private agentBrowserBridge: AgentBrowserBridge | null = null
private resolvedWorktreeCache: ResolvedWorktreeCache | null = null
private agentDetector: AgentDetector | null = null
private _orchestrationDb: OrchestrationDb | null = null
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
constructor(store: RuntimeStore | null = null, stats?: StatsCollector) {
this.store = store
@ -229,6 +248,22 @@ export class OrcaRuntimeService {
}
}
// Why: lazy initialization — the DB path depends on Electron's userData
// which may not be finalized until after app.ready. Also allows unit tests
// to inject an in-memory DB without touching the filesystem.
getOrchestrationDb(): OrchestrationDb {
if (!this._orchestrationDb) {
const { app } = require('electron')
const dbPath = join(app.getPath('userData'), 'orchestration.db')
this._orchestrationDb = new OrchestrationDb(dbPath)
}
return this._orchestrationDb
}
setOrchestrationDb(db: OrchestrationDb): void {
this._orchestrationDb = db
}
getRuntimeId(): string {
return this.runtimeId
}
@ -284,43 +319,77 @@ export class OrcaRuntimeService {
this.tabs = new Map(graph.tabs.map((tab) => [tab.tabId, tab]))
const nextLeaves = new Map<string, RuntimeLeafRecord>()
// Why: renderer reloads can briefly republish the same leaf with no ptyId;
// keep live CLI handles usable while the UI graph rebuilds.
const preserveLivePtysDuringReload = this.graphStatus === 'reloading'
for (const leaf of graph.leaves) {
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
const existing = this.leaves.get(leafKey)
const ptyId =
preserveLivePtysDuringReload && leaf.ptyId === null && existing?.ptyId
? existing.ptyId
: leaf.ptyId
const ptyGeneration =
existing && existing.ptyId !== leaf.ptyId
existing && existing.ptyId !== ptyId
? existing.ptyGeneration + 1
: (existing?.ptyGeneration ?? 0)
nextLeaves.set(leafKey, {
...leaf,
ptyId,
ptyGeneration,
connected: leaf.ptyId !== null,
writable: this.graphStatus === 'ready' && leaf.ptyId !== null,
lastOutputAt: existing?.ptyId === leaf.ptyId ? existing.lastOutputAt : null,
lastExitCode: existing?.ptyId === leaf.ptyId ? existing.lastExitCode : null,
tailBuffer: existing?.ptyId === leaf.ptyId ? existing.tailBuffer : [],
tailPartialLine: existing?.ptyId === leaf.ptyId ? existing.tailPartialLine : '',
tailTruncated: existing?.ptyId === leaf.ptyId ? existing.tailTruncated : false,
tailLinesTotal: existing?.ptyId === leaf.ptyId ? existing.tailLinesTotal : 0,
preview: existing?.ptyId === leaf.ptyId ? existing.preview : '',
lastAgentStatus: existing?.ptyId === leaf.ptyId ? existing.lastAgentStatus : null
connected: ptyId !== null,
writable: this.graphStatus === 'ready' && ptyId !== null,
lastOutputAt: existing?.ptyId === ptyId ? existing.lastOutputAt : null,
lastExitCode: existing?.ptyId === ptyId ? existing.lastExitCode : null,
tailBuffer: existing?.ptyId === ptyId ? existing.tailBuffer : [],
tailPartialLine: existing?.ptyId === ptyId ? existing.tailPartialLine : '',
tailTruncated: existing?.ptyId === ptyId ? existing.tailTruncated : false,
tailLinesTotal: existing?.ptyId === ptyId ? existing.tailLinesTotal : 0,
preview: existing?.ptyId === ptyId ? existing.preview : '',
lastAgentStatus: existing?.ptyId === ptyId ? existing.lastAgentStatus : null
})
if (existing && (existing.ptyId !== leaf.ptyId || existing.ptyGeneration !== ptyGeneration)) {
if (existing && (existing.ptyId !== ptyId || existing.ptyGeneration !== ptyGeneration)) {
this.invalidateLeafHandle(leafKey)
}
}
for (const oldLeafKey of this.leaves.keys()) {
if (!nextLeaves.has(oldLeafKey)) {
this.invalidateLeafHandle(oldLeafKey)
const oldLeaf = this.leaves.get(oldLeafKey)
if (
preserveLivePtysDuringReload &&
oldLeaf?.ptyId &&
this.handleByPtyId.has(oldLeaf.ptyId)
) {
// Why: a CLI-created agent keeps using its exported handle even if
// the reloaded renderer has not rebound the pane yet.
nextLeaves.set(oldLeafKey, oldLeaf)
} else {
this.invalidateLeafHandle(oldLeafKey)
}
}
}
const nextPtyIds = new Set(
[...nextLeaves.values()].map((leaf) => leaf.ptyId).filter((ptyId): ptyId is string => !!ptyId)
)
for (const [ptyId, leaf] of this.detachedPreAllocatedLeaves) {
if (nextPtyIds.has(ptyId) || !this.handleByPtyId.has(ptyId)) {
this.detachedPreAllocatedLeaves.delete(ptyId)
continue
}
nextLeaves.set(this.getLeafKey(leaf.tabId, leaf.leafId), leaf)
nextPtyIds.add(ptyId)
}
this.leaves = nextLeaves
this.graphStatus = 'ready'
this.refreshWritableFlags()
for (const leaf of this.leaves.values()) {
this.adoptPreAllocatedHandle(leaf)
}
// Why: createTerminal waits for the renderer's graph sync to populate the
// new leaf so it can return a handle. Drain callbacks after leaves update.
@ -331,11 +400,39 @@ export class OrcaRuntimeService {
return this.getStatus()
}
// Why: terminal handles are normally created lazily when first referenced via
// RPC, but agents need their own handle at spawn time (via ORCA_TERMINAL_HANDLE
// env var) so they can self-identify in orchestration messages without an
// extra RPC round-trip. Pre-allocating by ptyId lets issueHandle reuse it.
preAllocateHandleForPty(ptyId: string): string {
const existing = this.handleByPtyId.get(ptyId)
if (existing) {
return existing
}
const handle = this.createPreAllocatedTerminalHandle()
this.handleByPtyId.set(ptyId, handle)
return handle
}
createPreAllocatedTerminalHandle(): string {
return `term_${randomUUID()}`
}
registerPreAllocatedHandleForPty(ptyId: string, handle: string): void {
this.handleByPtyId.set(ptyId, handle)
for (const leaf of this.leaves.values()) {
if (leaf.ptyId === ptyId) {
this.adoptPreAllocatedHandle(leaf)
}
}
}
onPtySpawned(ptyId: string): void {
for (const leaf of this.leaves.values()) {
if (leaf.ptyId === ptyId) {
leaf.connected = true
leaf.writable = this.graphStatus === 'ready'
this.adoptPreAllocatedHandle(leaf)
}
}
}
@ -369,12 +466,15 @@ export class OrcaRuntimeService {
if (agentStatus !== null) {
const prevStatus = leaf.lastAgentStatus
leaf.lastAgentStatus = agentStatus
// Why: resolve tui-idle waiters only on working→idle, not working→permission.
// Permission means the agent is blocked on user approval (e.g. Gemini's
// "y/n" prompt) — it hasn't finished its task. Resolving tui-idle here
// would cause the CLI consumer to proceed while the agent is still waiting.
if (prevStatus === 'working' && agentStatus === 'idle') {
// Why: resolve tui-idle on any transition TO idle (not just working→idle).
// Claude Code may skip "working" entirely on fast tasks, going null→idle,
// and the coordinator's tui-idle waiter would hang forever waiting for a
// working→idle transition that never comes. Permission→idle is excluded:
// it means the agent was blocked on user approval and the user said no,
// which isn't a task-completion signal.
if (agentStatus === 'idle' && prevStatus !== 'idle') {
this.resolveTuiIdleWaiters(leaf)
this.deliverPendingMessages(leaf)
}
}
}
@ -387,10 +487,54 @@ export class OrcaRuntimeService {
if (leaf.ptyId !== ptyId) {
continue
}
this.detachedPreAllocatedLeaves.delete(ptyId)
leaf.connected = false
leaf.writable = false
leaf.lastExitCode = exitCode
this.resolveExitWaiters(leaf)
this.failActiveDispatchOnExit(leaf, exitCode)
}
}
// Why: Section 7.2 — the runtime detects agent exit directly and updates
// dispatch contexts immediately, rather than waiting for the coordinator's
// next poll cycle. This catches agent crashes and unexpected exits within
// milliseconds. The task is set back to 'pending' so it can be re-dispatched.
private failActiveDispatchOnExit(leaf: RuntimeLeafRecord, exitCode: number): void {
if (!this._orchestrationDb) {
return
}
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (!handle) {
return
}
const dispatch = this._orchestrationDb.getActiveDispatchForTerminal(handle)
if (!dispatch) {
return
}
const errorContext = `Agent exited with code ${exitCode}`
this._orchestrationDb.failDispatch(dispatch.id, errorContext)
// Why: create an escalation message so the coordinator is notified about
// the unexpected exit on its next check cycle, even if the circuit breaker
// hasn't tripped yet.
const run = this._orchestrationDb.getActiveCoordinatorRun()
if (run) {
this._orchestrationDb.insertMessage({
from: handle,
to: run.coordinator_handle,
subject: `Agent exited unexpectedly (code ${exitCode})`,
type: 'escalation',
priority: 'high',
payload: JSON.stringify({
taskId: dispatch.task_id,
exitCode,
handle
})
})
}
}
@ -527,10 +671,33 @@ export class OrcaRuntimeService {
if (payload === null) {
throw new Error('invalid_terminal_send')
}
const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false
if (!wrote) {
throw new Error('terminal_not_writable')
// Why: TUI apps (Claude Code, etc.) treat a single large write as a paste
// event. If \r is included in the same write as multi-line text, the TUI
// interprets it as part of the paste rather than a discrete Enter keypress.
// Splitting the text and the trailing control characters into separate
// writes with a small delay ensures the TUI processes the paste first,
// then receives Enter as a distinct input event.
const hasText = typeof action.text === 'string' && action.text.length > 0
const hasSuffix = action.enter || action.interrupt
if (hasText && hasSuffix) {
const textWrote = this.ptyController?.write(leaf.ptyId, action.text!) ?? false
if (!textWrote) {
throw new Error('terminal_not_writable')
}
const suffix = (action.enter ? '\r' : '') + (action.interrupt ? '\x03' : '')
await new Promise((resolve) => setTimeout(resolve, 500))
const suffixWrote = this.ptyController?.write(leaf.ptyId, suffix) ?? false
if (!suffixWrote) {
throw new Error('terminal_not_writable')
}
} else {
const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false
if (!wrote) {
throw new Error('terminal_not_writable')
}
}
return {
handle,
accepted: true,
@ -558,10 +725,6 @@ export class OrcaRuntimeService {
// Why: only 'idle' satisfies tui-idle, not 'permission'. Permission means the
// agent is blocked on user approval, not finished with its task.
if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') {
// Why: reset so the next `wait --for tui-idle` blocks until a fresh
// working→idle transition instead of resolving instantly from a stale
// status left over from a previous agent session.
leaf.lastAgentStatus = null
return buildTerminalWaitResult(handle, condition, leaf)
}
@ -581,7 +744,8 @@ export class OrcaRuntimeService {
condition,
resolve,
reject,
timeout: null
timeout: null,
pollInterval: null
}
if (effectiveTimeoutMs > 0) {
@ -606,8 +770,21 @@ export class OrcaRuntimeService {
if (getTerminalState(live.leaf) === 'exited') {
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
} else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === 'idle') {
live.leaf.lastAgentStatus = null
// Why: don't clear lastAgentStatus here. It's a factual record of the
// last detected OSC state, not a one-shot signal. Clearing it causes
// subsequent tui-idle waiters to hang even though the agent is idle —
// the first waiter consumes the status and all later ones see null.
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
} else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === null) {
// Why: for daemon-hosted terminals, lastAgentStatus stays null because
// PTY data doesn't flow through onPtyData. Check the renderer-synced
// title as a fast path before falling back to polling.
const fastPathTitle = live.leaf.paneTitle ?? this.tabs.get(live.leaf.tabId)?.title
if (fastPathTitle && detectAgentStatusFromTitle(fastPathTitle) === 'idle') {
this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf))
} else {
this.startTuiIdleFallbackPoll(waiter, live.leaf)
}
}
} catch (error) {
this.removeWaiter(waiter)
@ -1218,6 +1395,7 @@ export class OrcaRuntimeService {
// against whatever the renderer rebuilds next.
this.rendererGraphEpoch += 1
this.graphStatus = 'reloading'
this.rememberDetachedPreAllocatedLeaves()
this.handles.clear()
this.handleByLeafKey.clear()
this.rejectAllWaiters('terminal_handle_stale')
@ -1243,6 +1421,7 @@ export class OrcaRuntimeService {
}
this.graphStatus = 'unavailable'
this.authoritativeWindowId = null
this.rememberDetachedPreAllocatedLeaves()
this.tabs.clear()
this.leaves.clear()
this.handles.clear()
@ -1409,6 +1588,123 @@ export class OrcaRuntimeService {
}
}
// Why: group address resolution (Section 4.5) needs to query per-handle agent
// status without throwing on stale handles, so this returns null on any error.
getAgentStatusForHandle(handle: string): string | null {
try {
const { leaf } = this.getLiveLeafForHandle(handle)
return leaf.lastAgentStatus
} catch {
return null
}
}
// Why: OSC title detection via onPtyData is the tightest signal for agent
// presence, but the runtime may not see PTY data for daemon-hosted terminals
// (the daemon adapter stubs getForegroundProcess). This checks three signals
// in order: (1) lastAgentStatus from PTY data OSC titles, (2) the renderer-
// synced tab title (which reflects OSC titles from the xterm instance), and
// (3) the PTY foreground process. Returns true if any signal indicates a
// non-shell agent is running.
async isTerminalRunningAgent(handle: string): Promise<boolean> {
try {
const { leaf } = this.getLiveLeafForHandle(handle)
if (leaf.lastAgentStatus !== null) {
return true
}
// Why: check both the leaf-level pane title (synced from the renderer's
// runtimePaneTitlesByTabId) and the tab-level title. The tab title already
// includes OSC-enriched agent indicators (e.g. ✳ prefix) synced from the
// renderer's xterm instance.
const titleToCheck = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title
if (titleToCheck && detectAgentStatusFromTitle(titleToCheck) !== null) {
return true
}
if (!leaf.ptyId || !this.ptyController) {
return false
}
const fg = await this.ptyController.getForegroundProcess(leaf.ptyId)
if (!fg) {
return false
}
return !isShellProcess(fg)
} catch {
return false
}
}
deliverPendingMessagesForHandle(handle: string): void {
try {
const { leaf } = this.getLiveLeafForHandle(handle)
if (leaf.lastAgentStatus === 'idle') {
this.deliverPendingMessages(leaf)
}
} catch {
// Unknown or stale handles cannot be pushed immediately; the persisted
// message remains available via explicit check or future idle delivery.
}
}
// Why: after a message is inserted for a recipient, any blocking
// orchestration.check --wait calls watching that handle must be woken
// so they can return the new message immediately instead of polling.
notifyMessageArrived(handle: string): void {
const waiters = this.messageWaitersByHandle.get(handle)
if (!waiters || waiters.size === 0) {
return
}
for (const waiter of [...waiters]) {
this.resolveMessageWaiter(waiter)
}
}
waitForMessage(
handle: string,
options?: { typeFilter?: string[]; timeoutMs?: number }
): Promise<void> {
return new Promise((resolve) => {
const timeoutMs = options?.timeoutMs ?? MESSAGE_WAIT_DEFAULT_TIMEOUT_MS
const waiter: MessageWaiter = {
handle,
typeFilter: options?.typeFilter,
resolve,
timeout: null
}
waiter.timeout = setTimeout(() => {
this.removeMessageWaiter(waiter)
resolve()
}, timeoutMs)
let waiters = this.messageWaitersByHandle.get(handle)
if (!waiters) {
waiters = new Set()
this.messageWaitersByHandle.set(handle, waiters)
}
waiters.add(waiter)
})
}
private resolveMessageWaiter(waiter: MessageWaiter): void {
this.removeMessageWaiter(waiter)
waiter.resolve()
}
private removeMessageWaiter(waiter: MessageWaiter): void {
if (waiter.timeout) {
clearTimeout(waiter.timeout)
waiter.timeout = null
}
const waiters = this.messageWaitersByHandle.get(waiter.handle)
if (waiters) {
waiters.delete(waiter)
if (waiters.size === 0) {
this.messageWaitersByHandle.delete(waiter.handle)
}
}
}
private getLiveLeafForHandle(handle: string): {
record: TerminalHandleRecord
leaf: RuntimeLeafRecord
@ -1444,7 +1740,10 @@ export class OrcaRuntimeService {
}
}
const handle = `term_${randomUUID()}`
const handle = this.adoptPreAllocatedHandle(leaf) ?? `term_${randomUUID()}`
if (this.handles.has(handle)) {
return handle
}
this.handles.set(handle, {
handle,
runtimeId: this.runtimeId,
@ -1459,6 +1758,29 @@ export class OrcaRuntimeService {
return handle
}
private adoptPreAllocatedHandle(leaf: RuntimeLeafRecord): string | null {
if (!leaf.ptyId) {
return null
}
const preAllocated = this.handleByPtyId.get(leaf.ptyId)
if (!preAllocated) {
return null
}
const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId)
this.handles.set(preAllocated, {
handle: preAllocated,
runtimeId: this.runtimeId,
rendererGraphEpoch: this.rendererGraphEpoch,
worktreeId: leaf.worktreeId,
tabId: leaf.tabId,
leafId: leaf.leafId,
ptyId: leaf.ptyId,
ptyGeneration: leaf.ptyGeneration
})
this.handleByLeafKey.set(leafKey, preAllocated)
return preAllocated
}
private refreshWritableFlags(): void {
for (const leaf of this.leaves.values()) {
leaf.writable = this.graphStatus === 'ready' && leaf.connected && leaf.ptyId !== null
@ -1475,8 +1797,18 @@ export class OrcaRuntimeService {
this.rejectWaitersForHandle(handle, 'terminal_handle_stale')
}
private rememberDetachedPreAllocatedLeaves(): void {
for (const leaf of this.leaves.values()) {
if (leaf.ptyId && this.handleByPtyId.has(leaf.ptyId)) {
// Why: ORCA_TERMINAL_HANDLE is an agent identity, so CLI control should
// survive renderer graph loss as long as the underlying PTY is alive.
this.detachedPreAllocatedLeaves.set(leaf.ptyId, leaf)
}
}
}
private resolveExitWaiters(leaf: RuntimeLeafRecord): void {
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
const handle = this.issueHandle(leaf)
if (!handle) {
return
}
@ -1513,6 +1845,109 @@ export class OrcaRuntimeService {
}
}
// Why: OSC title detection via onPtyData is the primary signal for tui-idle,
// but daemon-hosted terminals don't flow PTY data through the runtime, and
// some agents don't emit recognized titles on startup. This fallback polls
// two signals: (1) the renderer-synced tab title (reflects xterm's OSC title
// handler, works even for daemon terminals), and (2) the PTY foreground process
// + output quiescence. The poll self-cancels when the primary OSC path fires.
private startTuiIdleFallbackPoll(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void {
waiter.pollInterval = setInterval(async () => {
try {
// If OSC detection via onPtyData kicked in, stop — the primary path
// will handle (or has already handled) resolution.
if (leaf.lastAgentStatus !== null) {
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
waiter.pollInterval = null
}
return
}
// Why: check the renderer-synced title. For daemon-hosted terminals,
// this is the only path where OSC titles are visible to the runtime.
const pollTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title
if (pollTitle) {
const titleStatus = detectAgentStatusFromTitle(pollTitle)
if (titleStatus === 'idle') {
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
waiter.pollInterval = null
}
this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
return
}
}
// Foreground process fallback: if the daemon/local provider can report
// the process and it's a non-shell with quiet output, treat as idle.
if (leaf.ptyId && this.ptyController) {
const fg = await this.ptyController.getForegroundProcess(leaf.ptyId)
if (fg && !isShellProcess(fg)) {
const quietMs = leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0
if (quietMs >= TUI_IDLE_QUIESCENCE_MS) {
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
waiter.pollInterval = null
}
this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf))
}
}
}
} catch {
// Swallow transient PTY inspection errors and keep polling.
}
}, TUI_IDLE_POLL_INTERVAL_MS)
}
// Why: push-on-idle delivery — when an agent transitions working→idle, check
// for unread orchestration messages addressed to that terminal and inject them
// into the PTY. This is event-driven (no polling) because the runtime owns
// both the message store and terminal status detection.
private deliverPendingMessages(leaf: RuntimeLeafRecord): void {
if (!this._orchestrationDb) {
return
}
const handle = this.handleByLeafKey.get(this.getLeafKey(leaf.tabId, leaf.leafId))
if (!handle) {
return
}
const unread = this._orchestrationDb.getUnreadMessages(handle)
if (unread.length === 0) {
return
}
if (!leaf.writable || !leaf.ptyId) {
return
}
const payload = formatMessagesForInjection(unread)
const wrote = this.ptyController?.write(leaf.ptyId, payload) ?? false
if (!wrote) {
return
}
// Why: Claude Code treats large single PTY writes as paste events and
// swallows a \r included in the same write. Send Enter separately after
// a delay so the agent processes the pasted message first. Mark messages
// as read only after \r is confirmed, so failed deliveries stay queued.
const ptyId = leaf.ptyId
setTimeout(() => {
try {
if (!leaf.writable) {
return
}
const submitted = this.ptyController?.write(ptyId, '\r') ?? false
if (submitted) {
this._orchestrationDb?.markAsRead(unread.map((m) => m.id))
}
} catch {
// Terminal may have closed during the delay — messages stay unread
// and will be re-delivered on the next idle transition.
}
}, 500)
}
private resolveWaiter(waiter: TerminalWaiter, result: RuntimeTerminalWait): void {
this.removeWaiter(waiter)
waiter.resolve(result)
@ -1539,6 +1974,9 @@ export class OrcaRuntimeService {
if (waiter.timeout) {
clearTimeout(waiter.timeout)
}
if (waiter.pollInterval) {
clearInterval(waiter.pollInterval)
}
const waiters = this.waitersByHandle.get(waiter.handle)
if (!waiters) {
return
@ -2715,6 +3153,9 @@ function buildSendPayload(action: {
// will ever fire. A 5-minute ceiling prevents indefinite hangs while still
// giving real agent tasks plenty of time to complete.
const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000
const TUI_IDLE_POLL_INTERVAL_MS = 2000
const TUI_IDLE_QUIESCENCE_MS = 3000
const MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000
function buildTerminalWaitResult(
handle: string,

View File

@ -0,0 +1,379 @@
/* eslint-disable max-lines -- Why: coordinator tests cover dispatch, DAG ordering, escalation, decision gates, concurrency, and stop — splitting by category would scatter shared setup without improving clarity. */
import { afterEach, describe, expect, it } from 'vitest'
import { OrchestrationDb } from './db'
import { Coordinator, type CoordinatorRuntime } from './coordinator'
function createMockRuntime(): CoordinatorRuntime & {
sentMessages: { handle: string; text: string }[]
terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[]
createdTerminals: string[]
} {
const mock = {
sentMessages: [] as { handle: string; text: string }[],
terminals: [] as {
handle: string
worktreeId: string
connected: boolean
writable: boolean
}[],
createdTerminals: [] as string[],
async sendTerminal(handle: string, action: { text?: string }) {
mock.sentMessages.push({ handle, text: action.text ?? '' })
return { handle, accepted: true, bytesWritten: 0 }
},
async listTerminals() {
return { terminals: mock.terminals }
},
async createTerminal(_worktree?: string, opts?: { title?: string }) {
const handle = `term_worker_${mock.createdTerminals.length}`
mock.createdTerminals.push(handle)
mock.terminals.push({ handle, worktreeId: 'wt1', connected: true, writable: true })
return { handle, worktreeId: 'wt1', title: opts?.title ?? '' }
},
async waitForTerminal(handle: string) {
return { handle, condition: 'exit' }
}
}
return mock
}
describe('Coordinator', () => {
let db: OrchestrationDb
afterEach(() => {
db?.close()
})
it('throws if no tasks exist', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
const coordinator = new Coordinator(db, runtime, {
spec: 'do stuff',
coordinatorHandle: 'coord'
})
await expect(coordinator.run()).rejects.toThrow('No tasks found')
})
it('dispatches a ready task to an available terminal', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
const task = db.createTask({ spec: 'implement feature' })
// Simulate worker_done arriving after dispatch
const coordinator = new Coordinator(db, runtime, {
spec: 'build it',
coordinatorHandle: 'coord',
pollIntervalMs: 50
})
// Run coordinator in background, then simulate completion
const runPromise = coordinator.run()
// Wait for dispatch to happen
await new Promise((r) => {
setTimeout(r, 100)
})
// Simulate the worker completing
db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId: task.id, filesModified: ['a.ts'] })
})
const result = await runPromise
expect(result.status).toBe('completed')
expect(result.completedTasks).toContain(task.id)
expect(runtime.sentMessages.length).toBeGreaterThan(0)
})
it('creates a terminal when none are available', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
const task = db.createTask({ spec: 'work' })
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 50
})
const runPromise = coordinator.run()
await new Promise((r) => {
setTimeout(r, 100)
})
expect(runtime.createdTerminals.length).toBe(1)
// Complete the task
db.insertMessage({
from: runtime.createdTerminals[0],
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId: task.id })
})
const result = await runPromise
expect(result.status).toBe('completed')
})
it('handles escalation and circuit breaker', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
runtime.terminals = [
{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true },
{ handle: 'term_b', worktreeId: 'wt1', connected: true, writable: true }
]
const task = db.createTask({ spec: 'risky work' })
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 50
})
const runPromise = coordinator.run()
// Send 3 escalations to trigger circuit breaker
for (let i = 0; i < 3; i++) {
await new Promise((r) => {
setTimeout(r, 100)
})
db.insertMessage({
from: `term_${i === 0 ? 'a' : 'b'}`,
to: 'coord',
subject: `Failed attempt ${i + 1}`,
type: 'escalation',
payload: JSON.stringify({ taskId: task.id })
})
}
const result = await runPromise
expect(result.status).toBe('failed')
expect(result.failedTasks).toContain(task.id)
})
it('reports failed when dispatch send failures circuit-break in the DB', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
runtime.sendTerminal = async () => {
throw new Error('terminal_not_writable')
}
const task = db.createTask({ spec: 'cannot dispatch' })
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 10
})
const result = await coordinator.run()
expect(result.status).toBe('failed')
expect(result.failedTasks).toContain(task.id)
expect(db.getTask(task.id)?.status).toBe('failed')
})
it('handles decision gate blocking and resolution', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
const task = db.createTask({ spec: 'needs approval' })
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 50
})
const runPromise = coordinator.run()
// Wait for dispatch
await new Promise((r) => {
setTimeout(r, 100)
})
// Worker sends decision gate
db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Need approval',
type: 'decision_gate',
payload: JSON.stringify({
taskId: task.id,
question: 'Proceed with destructive migration?',
options: ['yes', 'no']
})
})
await new Promise((r) => {
setTimeout(r, 100)
})
// Verify task is blocked
const blocked = db.getTask(task.id)
expect(blocked?.status).toBe('blocked')
expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined()
// Resolve the gate
const gates = db.listGates({ taskId: task.id, status: 'pending' })
expect(gates.length).toBe(1)
db.resolveGate(gates[0].id, 'yes')
// Wait for re-dispatch and simulate completion
await new Promise((r) => {
setTimeout(r, 200)
})
db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId: task.id })
})
const result = await runPromise
expect(result.status).toBe('completed')
expect(result.completedTasks).toContain(task.id)
})
it('respects task DAG ordering', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
runtime.terminals = [{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true }]
const t1 = db.createTask({ spec: 'first' })
const t2 = db.createTask({ spec: 'second', deps: [t1.id] })
expect(t2.status).toBe('pending')
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 50
})
const runPromise = coordinator.run()
// Wait for t1 dispatch
await new Promise((r) => {
setTimeout(r, 100)
})
// t2 should still be pending
expect(db.getTask(t2.id)?.status).toBe('pending')
// Complete t1
db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId: t1.id })
})
// Wait for t2 to be promoted and dispatched
await new Promise((r) => {
setTimeout(r, 200)
})
// t2 should now be dispatched
const t2Status = db.getTask(t2.id)?.status
expect(t2Status === 'dispatched' || t2Status === 'ready').toBe(true)
// Complete t2
db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId: t2.id })
})
const result = await runPromise
expect(result.status).toBe('completed')
expect(result.completedTasks).toContain(t1.id)
expect(result.completedTasks).toContain(t2.id)
})
it('respects maxConcurrent limit', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
runtime.terminals = [
{ handle: 'term_a', worktreeId: 'wt1', connected: true, writable: true },
{ handle: 'term_b', worktreeId: 'wt1', connected: true, writable: true },
{ handle: 'term_c', worktreeId: 'wt1', connected: true, writable: true }
]
const t1 = db.createTask({ spec: 'one' })
const t2 = db.createTask({ spec: 'two' })
const t3 = db.createTask({ spec: 'three' })
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 50,
maxConcurrent: 2
})
const runPromise = coordinator.run()
await new Promise((r) => {
setTimeout(r, 100)
})
// Only 2 should be dispatched
const dispatched = db.listTasks({ status: 'dispatched' })
expect(dispatched.length).toBe(2)
// Complete all tasks
for (const task of [t1, t2, t3]) {
db.insertMessage({
from: 'term_a',
to: 'coord',
subject: 'Done',
type: 'worker_done',
payload: JSON.stringify({ taskId: task.id })
})
await new Promise((r) => {
setTimeout(r, 100)
})
}
const result = await runPromise
expect(result.status).toBe('completed')
})
it('can be stopped', async () => {
db = new OrchestrationDb(':memory:')
const runtime = createMockRuntime()
db.createTask({ spec: 'never finishes' })
const coordinator = new Coordinator(db, runtime, {
spec: 'go',
coordinatorHandle: 'coord',
pollIntervalMs: 50
})
const runPromise = coordinator.run()
await new Promise((r) => {
setTimeout(r, 100)
})
coordinator.stop()
const result = await runPromise
expect(result.status).toBe('failed')
})
})

View File

@ -0,0 +1,491 @@
/* eslint-disable max-lines -- Why: the coordinator keeps message processing, task dispatch, gate handling, escalation, and convergence checking in one class so the polling loop can make atomic decisions across all these concerns without split-brain behavior. */
import type { OrchestrationDb } from './db'
import type { MessageRow, TaskRow, CoordinatorStatus } from './types'
import { buildDispatchPreamble } from './preamble'
export type CoordinatorRuntime = {
sendTerminal(handle: string, action: { text?: string; enter?: boolean }): Promise<unknown>
listTerminals(
worktreeSelector?: string,
limit?: number
): Promise<{
terminals: { handle: string; worktreeId: string; connected: boolean; writable: boolean }[]
}>
createTerminal(
worktreeSelector?: string,
opts?: { command?: string; title?: string }
): Promise<{ handle: string; worktreeId: string }>
waitForTerminal(
handle: string,
options?: { condition?: string; timeoutMs?: number }
): Promise<{ handle: string; condition: string }>
}
export type CoordinatorOptions = {
spec: string
coordinatorHandle: string
pollIntervalMs?: number
maxConcurrent?: number
worktree?: string
onLog?: (msg: string) => void
}
type CoordinatorState = {
runId: string
phase: 'decomposing' | 'dispatching' | 'monitoring' | 'merging' | 'done'
completedTasks: string[]
failedTasks: string[]
escalations: MessageRow[]
}
const DEFAULT_POLL_MS = 2000
const MAX_CONCURRENT_DEFAULT = 4
export class Coordinator {
private db: OrchestrationDb
private runtime: CoordinatorRuntime
private state: CoordinatorState
private stopped = false
private opts: Required<Omit<CoordinatorOptions, 'onLog' | 'worktree'>> & {
onLog: (msg: string) => void
worktree?: string
}
constructor(db: OrchestrationDb, runtime: CoordinatorRuntime, options: CoordinatorOptions) {
this.db = db
this.runtime = runtime
this.opts = {
spec: options.spec,
coordinatorHandle: options.coordinatorHandle,
pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_MS,
maxConcurrent: options.maxConcurrent ?? MAX_CONCURRENT_DEFAULT,
worktree: options.worktree,
onLog: options.onLog ?? (() => {})
}
this.state = {
runId: '',
phase: 'decomposing',
completedTasks: [],
failedTasks: [],
escalations: []
}
}
async run(): Promise<{
runId: string
status: CoordinatorStatus
completedTasks: string[]
failedTasks: string[]
escalations: MessageRow[]
}> {
const run = this.db.createCoordinatorRun({
spec: this.opts.spec,
coordinatorHandle: this.opts.coordinatorHandle,
pollIntervalMs: this.opts.pollIntervalMs
})
return this.executeLoop(run.id)
}
// Why: the RPC handler creates the coordinator_runs record itself so it can
// return the run ID immediately, then starts the loop in the background.
// This method skips the DB insert and uses the pre-created run ID.
async runFromExistingRun(runId: string): Promise<{
runId: string
status: CoordinatorStatus
completedTasks: string[]
failedTasks: string[]
escalations: MessageRow[]
}> {
return this.executeLoop(runId)
}
private async executeLoop(runId: string): Promise<{
runId: string
status: CoordinatorStatus
completedTasks: string[]
failedTasks: string[]
escalations: MessageRow[]
}> {
this.state.runId = runId
this.opts.onLog(`Coordinator run ${runId} started`)
try {
await this.decompose()
while (!this.stopped) {
const converged = await this.tick()
if (converged) {
break
}
await this.sleep(this.opts.pollIntervalMs)
}
// Why: if stopped early, treat it as failed since tasks are incomplete.
// Also failed if any task explicitly failed.
const tasks = this.db.listTasks()
const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed')
const failedTasks = [
...new Set([
...this.state.failedTasks,
...tasks.filter((task) => task.status === 'failed').map((task) => task.id)
])
]
const finalStatus =
this.stopped || failedTasks.length > 0 || !allDone ? 'failed' : 'completed'
this.db.updateCoordinatorRun(runId, finalStatus)
this.opts.onLog(`Coordinator run ${runId} ${finalStatus}`)
return {
runId,
status: finalStatus,
completedTasks: this.state.completedTasks,
failedTasks,
escalations: this.state.escalations
}
} catch (err) {
this.db.updateCoordinatorRun(runId, 'failed')
throw err
}
}
stop(): void {
this.stopped = true
}
// Why: the coordinator decomposes the top-level spec into a task DAG.
// For now, tasks must be pre-created before calling run(). The spec is
// stored for context but decomposition is the caller's responsibility —
// AI-driven decomposition belongs in a future phase where the coordinator
// itself is an LLM agent.
private async decompose(): Promise<void> {
this.state.phase = 'decomposing'
const existing = this.db.listTasks()
if (existing.length === 0) {
throw new Error(
'No tasks found. Create tasks with orchestration.taskCreate before running the coordinator.'
)
}
this.opts.onLog(`Found ${existing.length} tasks in DAG`)
this.state.phase = 'dispatching'
}
private async tick(): Promise<boolean> {
this.processMessages()
this.processEscalations()
this.processDecisionGates()
await this.dispatchReadyTasks()
return this.checkConvergence()
}
private processMessages(): void {
const messages = this.db.getUnreadMessages(this.opts.coordinatorHandle)
if (messages.length === 0) {
return
}
for (const msg of messages) {
switch (msg.type) {
case 'worker_done':
this.handleWorkerDone(msg)
break
case 'escalation':
this.handleEscalation(msg)
break
case 'decision_gate':
this.handleDecisionGateMessage(msg)
break
case 'status':
this.opts.onLog(`Status from ${msg.from_handle}: ${msg.subject}`)
break
default:
break
}
}
this.db.markAsRead(messages.map((m) => m.id))
}
private handleWorkerDone(msg: MessageRow): void {
this.opts.onLog(`Worker done: ${msg.from_handle}${msg.subject}`)
let payload: { taskId?: string; filesModified?: string[] } = {}
if (msg.payload) {
try {
payload = JSON.parse(msg.payload)
} catch {
this.opts.onLog(`Warning: invalid payload in worker_done from ${msg.from_handle}`)
}
}
const taskId = payload.taskId
if (!taskId) {
this.opts.onLog(`Warning: worker_done without taskId from ${msg.from_handle}`)
return
}
const task = this.db.getTask(taskId)
if (!task) {
this.opts.onLog(`Warning: worker_done for unknown task ${taskId}`)
return
}
const result = JSON.stringify({
completedBy: msg.from_handle,
filesModified: payload.filesModified ?? [],
completedAt: new Date().toISOString()
})
this.db.updateTaskStatus(taskId, 'completed', result)
this.state.completedTasks.push(taskId)
// Why: complete the dispatch context so the terminal is freed for
// subsequent task assignments.
const dispatch = this.db.getDispatchContext(taskId)
if (dispatch) {
this.db.completeDispatch(dispatch.id)
}
this.opts.onLog(`Task ${taskId} completed`)
}
private handleEscalation(msg: MessageRow): void {
this.opts.onLog(`Escalation from ${msg.from_handle}: ${msg.subject}`)
this.state.escalations.push(msg)
let taskId: string | undefined
if (msg.payload) {
try {
const payload = JSON.parse(msg.payload)
taskId = payload.taskId
} catch {
// Escalation without structured payload — log subject as context
}
}
if (!taskId) {
return
}
const task = this.db.getTask(taskId)
if (!task || task.status === 'completed' || task.status === 'failed') {
return
}
const dispatch = this.db.getDispatchContext(taskId)
if (!dispatch) {
return
}
// Why: fail the dispatch so the circuit breaker increments. If under
// the threshold, the task returns to 'pending' and will be re-dispatched
// to a (potentially different) terminal on the next tick.
const updated = this.db.failDispatch(dispatch.id, msg.subject)
if (updated?.status === 'circuit_broken') {
this.opts.onLog(`Task ${taskId} circuit broken after repeated failures`)
this.db.updateTaskStatus(taskId, 'failed', `Circuit broken: ${msg.subject}`)
this.state.failedTasks.push(taskId)
} else {
this.opts.onLog(`Task ${taskId} will be retried (failure ${updated?.failure_count ?? 0}/3)`)
}
}
private handleDecisionGateMessage(msg: MessageRow): void {
this.opts.onLog(`Decision gate from ${msg.from_handle}: ${msg.subject}`)
let payload: { taskId?: string; question?: string; options?: string[] } = {}
if (msg.payload) {
try {
payload = JSON.parse(msg.payload)
} catch {
return
}
}
if (!payload.taskId || !payload.question) {
this.opts.onLog(`Warning: decision_gate missing taskId or question`)
return
}
this.db.createGate({
taskId: payload.taskId,
question: payload.question,
options: payload.options
})
this.opts.onLog(`Task ${payload.taskId} blocked on decision gate`)
}
private processEscalations(): void {
// Why: escalation processing is handled inline in processMessages via
// handleEscalation. This method exists as a hook for future escalation
// policies (e.g., auto-reassign after N minutes, notify external systems).
}
private processDecisionGates(): void {
// Why: pending gates that haven't been resolved externally are surfaced
// here. In production, the coordinator UI or a human operator resolves
// gates via orchestration.gateResolve. The coordinator does not auto-
// resolve gates — that would defeat their purpose as approval checkpoints.
const pendingGates = this.db.listGates({ status: 'pending' })
for (const gate of pendingGates) {
const task = this.db.getTask(gate.task_id)
if (task && task.status !== 'blocked') {
// Why: gate exists but task isn't blocked — inconsistent state.
// Re-block the task to maintain the invariant.
this.db.updateTaskStatus(gate.task_id, 'blocked')
}
}
}
private async dispatchReadyTasks(): Promise<void> {
this.state.phase = 'dispatching'
const readyTasks = this.db.listTasks({ ready: true })
if (readyTasks.length === 0) {
return
}
// Why: count currently dispatched tasks to enforce concurrency limit.
const dispatched = this.db.listTasks({ status: 'dispatched' })
let slotsAvailable = this.opts.maxConcurrent - dispatched.length
if (slotsAvailable <= 0) {
return
}
const terminals = await this.getAvailableTerminals()
if (terminals.length === 0 && slotsAvailable > 0) {
// Why: no idle terminals exist — create one for the next task.
// Only create one per tick to avoid spawning many terminals at once.
try {
const created = await this.runtime.createTerminal(this.opts.worktree, {
title: `Worker: ${readyTasks[0].spec.slice(0, 40)}`
})
terminals.push(created.handle)
this.opts.onLog(`Created worker terminal ${created.handle}`)
} catch (err) {
this.opts.onLog(`Failed to create terminal: ${err}`)
return
}
}
for (const task of readyTasks) {
if (slotsAvailable <= 0 || terminals.length === 0) {
break
}
const targetHandle = terminals.shift()!
slotsAvailable--
try {
await this.dispatchTask(task, targetHandle)
} catch (err) {
this.opts.onLog(`Failed to dispatch task ${task.id}: ${err}`)
}
}
}
private async dispatchTask(task: TaskRow, targetHandle: string): Promise<void> {
const dispatch = this.db.createDispatchContext(task.id, targetHandle)
// Why: agents dispatched by the coordinator must use orca-dev in dev mode
// so they talk to the dev runtime's socket, not production (Section 6.4).
const preamble = buildDispatchPreamble({
taskId: task.id,
taskSpec: task.spec,
coordinatorHandle: this.opts.coordinatorHandle,
devMode: process.env.ORCA_USER_DATA_PATH?.includes('orca-dev')
})
// Why: check if the task was previously blocked by a decision gate that
// has since been resolved. Include the resolution in the preamble so the
// worker knows the decision outcome.
const gates = this.db.listGates({ taskId: task.id, status: 'resolved' })
let gateContext = ''
if (gates.length > 0) {
const latest = gates.at(-1)!
gateContext = `\n\n--- DECISION GATE RESOLVED ---\nQuestion: ${latest.question}\nResolution: ${latest.resolution}\n---\n`
}
try {
await this.runtime.sendTerminal(targetHandle, {
text: preamble + gateContext,
enter: true
})
} catch (err) {
const updated = this.db.failDispatch(
dispatch.id,
err instanceof Error ? err.message : String(err)
)
if (updated?.status === 'circuit_broken') {
this.state.failedTasks.push(task.id)
}
throw err
}
this.opts.onLog(`Dispatched task ${task.id} to ${targetHandle}`)
this.state.phase = 'monitoring'
}
private async getAvailableTerminals(): Promise<string[]> {
try {
const result = await this.runtime.listTerminals(this.opts.worktree)
const dispatched = this.db.listTasks({ status: 'dispatched' })
const busyHandles = new Set<string>()
for (const task of dispatched) {
const ctx = this.db.getDispatchContext(task.id)
if (ctx?.assignee_handle) {
busyHandles.add(ctx.assignee_handle)
}
}
// Why: exclude the coordinator's own terminal, terminals with active
// dispatches, and disconnected terminals. The dispatch-lock in
// createDispatchContext prevents double-dispatch even if a terminal
// looks available here — this filter is an optimization, not a
// correctness constraint.
return result.terminals
.filter(
(t) =>
t.handle !== this.opts.coordinatorHandle &&
!busyHandles.has(t.handle) &&
t.connected &&
t.writable
)
.map((t) => t.handle)
} catch {
return []
}
}
private checkConvergence(): boolean {
const tasks = this.db.listTasks()
if (tasks.length === 0) {
return true
}
const allDone = tasks.every((t) => t.status === 'completed' || t.status === 'failed')
if (allDone) {
this.state.phase = 'done'
return true
}
// Why: detect stuck state — no ready or dispatched tasks, but some are
// still pending/blocked. This means deps can never be satisfied.
const active = tasks.filter(
(t) => t.status === 'ready' || t.status === 'dispatched' || t.status === 'pending'
)
const blocked = tasks.filter((t) => t.status === 'blocked')
if (active.length === 0 && blocked.length > 0) {
this.opts.onLog(
`Stuck: ${blocked.length} tasks blocked with no active tasks. Resolve decision gates to continue.`
)
}
return false
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
}

View File

@ -0,0 +1,457 @@
/* eslint-disable max-lines -- Why: DB tests cover messages, tasks, dispatch contexts, decision gates, coordinator runs, and lifecycle in one suite to share the createDb() helper and afterEach cleanup. */
import { afterEach, describe, expect, it } from 'vitest'
import { OrchestrationDb } from './db'
import type { MessageType } from './db'
describe('OrchestrationDb', () => {
let db: OrchestrationDb
afterEach(() => {
db?.close()
})
function createDb(): OrchestrationDb {
db = new OrchestrationDb(':memory:')
return db
}
describe('messages', () => {
it('inserts and retrieves a message', () => {
const d = createDb()
const msg = d.insertMessage({
from: 'term_a',
to: 'term_b',
subject: 'hello',
body: 'world'
})
expect(msg.id).toMatch(/^msg_/)
expect(msg.from_handle).toBe('term_a')
expect(msg.to_handle).toBe('term_b')
expect(msg.subject).toBe('hello')
expect(msg.body).toBe('world')
expect(msg.type).toBe('status')
expect(msg.priority).toBe('normal')
expect(msg.read).toBe(0)
expect(msg.sequence).toBeGreaterThan(0)
})
it('returns unread messages in sequence order', () => {
const d = createDb()
d.insertMessage({ from: 'a', to: 'b', subject: 'first' })
d.insertMessage({ from: 'a', to: 'b', subject: 'second' })
d.insertMessage({ from: 'a', to: 'c', subject: 'other' })
const unread = d.getUnreadMessages('b')
expect(unread).toHaveLength(2)
expect(unread[0].subject).toBe('first')
expect(unread[1].subject).toBe('second')
})
it('filters unread by type', () => {
const d = createDb()
d.insertMessage({ from: 'a', to: 'b', subject: 'status msg', type: 'status' })
d.insertMessage({ from: 'a', to: 'b', subject: 'done msg', type: 'worker_done' })
const filtered = d.getUnreadMessages('b', ['worker_done'])
expect(filtered).toHaveLength(1)
expect(filtered[0].type).toBe('worker_done')
})
it('marks messages as read', () => {
const d = createDb()
const m1 = d.insertMessage({ from: 'a', to: 'b', subject: 'one' })
const m2 = d.insertMessage({ from: 'a', to: 'b', subject: 'two' })
d.markAsRead([m1.id])
const unread = d.getUnreadMessages('b')
expect(unread).toHaveLength(1)
expect(unread[0].id).toBe(m2.id)
})
it('stores typed payload and thread_id', () => {
const d = createDb()
const payload = JSON.stringify({ taskId: 'task_abc', filesModified: ['src/a.ts'] })
const msg = d.insertMessage({
from: 'a',
to: 'b',
subject: 'done',
type: 'worker_done',
priority: 'high',
threadId: 'thread_1',
payload
})
expect(msg.type).toBe('worker_done')
expect(msg.priority).toBe('high')
expect(msg.thread_id).toBe('thread_1')
expect(msg.payload).toBe(payload)
})
it('rejects invalid message type', () => {
const d = createDb()
expect(() =>
d.insertMessage({
from: 'a',
to: 'b',
subject: 'bad',
type: 'invalid' as MessageType
})
).toThrow()
})
it('getInbox returns all messages across recipients', () => {
const d = createDb()
d.insertMessage({ from: 'a', to: 'b', subject: 'one' })
d.insertMessage({ from: 'a', to: 'c', subject: 'two' })
d.insertMessage({ from: 'b', to: 'a', subject: 'three' })
const inbox = d.getInbox(10)
expect(inbox).toHaveLength(3)
})
it('getMessageById returns the correct message', () => {
const d = createDb()
const msg = d.insertMessage({ from: 'a', to: 'b', subject: 'test' })
const found = d.getMessageById(msg.id)
expect(found?.subject).toBe('test')
expect(d.getMessageById('msg_nonexistent')).toBeUndefined()
})
})
describe('tasks', () => {
it('creates a task with no deps as ready', () => {
const d = createDb()
const task = d.createTask({ spec: 'do something' })
expect(task.id).toMatch(/^task_/)
expect(task.status).toBe('ready')
expect(task.deps).toBe('[]')
})
it('creates a task with deps as pending', () => {
const d = createDb()
const parent = d.createTask({ spec: 'parent' })
const child = d.createTask({ spec: 'child', deps: [parent.id] })
expect(child.status).toBe('pending')
expect(JSON.parse(child.deps)).toEqual([parent.id])
})
it('promotes pending tasks when deps complete', () => {
const d = createDb()
const t1 = d.createTask({ spec: 'first' })
const t2 = d.createTask({ spec: 'second', deps: [t1.id] })
expect(d.getTask(t2.id)?.status).toBe('pending')
d.updateTaskStatus(t1.id, 'completed')
expect(d.getTask(t2.id)?.status).toBe('ready')
})
it('does not promote task until ALL deps complete', () => {
const d = createDb()
const t1 = d.createTask({ spec: 'a' })
const t2 = d.createTask({ spec: 'b' })
const t3 = d.createTask({ spec: 'c', deps: [t1.id, t2.id] })
d.updateTaskStatus(t1.id, 'completed')
expect(d.getTask(t3.id)?.status).toBe('pending')
d.updateTaskStatus(t2.id, 'completed')
expect(d.getTask(t3.id)?.status).toBe('ready')
})
it('sets completed_at on completion', () => {
const d = createDb()
const task = d.createTask({ spec: 'do it' })
const updated = d.updateTaskStatus(task.id, 'completed', '{"result": true}')
expect(updated?.completed_at).toBeTruthy()
expect(updated?.result).toBe('{"result": true}')
})
it('completing a task frees its active dispatch context', () => {
const d = createDb()
const task = d.createTask({ spec: 'do it' })
d.createDispatchContext(task.id, 'term_a')
d.updateTaskStatus(task.id, 'completed')
expect(d.getActiveDispatchForTerminal('term_a')).toBeUndefined()
expect(d.getDispatchContext(task.id)?.status).toBe('completed')
})
it('listTasks filters by status', () => {
const d = createDb()
d.createTask({ spec: 'ready task' })
const t2 = d.createTask({ spec: 'another' })
d.updateTaskStatus(t2.id, 'completed')
expect(d.listTasks({ status: 'ready' })).toHaveLength(1)
expect(d.listTasks({ status: 'completed' })).toHaveLength(1)
expect(d.listTasks({ ready: true })).toHaveLength(1)
})
it('listTasks returns all when no filter', () => {
const d = createDb()
d.createTask({ spec: 'one' })
d.createTask({ spec: 'two' })
expect(d.listTasks()).toHaveLength(2)
})
it('supports parent_id for task decomposition', () => {
const d = createDb()
const parent = d.createTask({ spec: 'parent' })
const child = d.createTask({ spec: 'child', parentId: parent.id })
expect(child.parent_id).toBe(parent.id)
})
})
describe('dispatch contexts', () => {
it('creates a dispatch context and marks task as dispatched', () => {
const d = createDb()
const task = d.createTask({ spec: 'work' })
const ctx = d.createDispatchContext(task.id, 'term_worker')
expect(ctx.id).toMatch(/^ctx_/)
expect(ctx.task_id).toBe(task.id)
expect(ctx.assignee_handle).toBe('term_worker')
expect(ctx.status).toBe('dispatched')
expect(d.getTask(task.id)?.status).toBe('dispatched')
})
it('rejects dispatch for non-ready tasks', () => {
const d = createDb()
const parent = d.createTask({ spec: 'parent' })
const child = d.createTask({ spec: 'child', deps: [parent.id] })
expect(() => d.createDispatchContext(child.id, 'term_worker')).toThrow(
/only ready tasks can be dispatched/
)
})
it('rejects dispatch to an occupied terminal', () => {
const d = createDb()
const t1 = d.createTask({ spec: 'first' })
const t2 = d.createTask({ spec: 'second' })
d.createDispatchContext(t1.id, 'term_worker')
expect(() => d.createDispatchContext(t2.id, 'term_worker')).toThrow(
/already has an active dispatch/
)
})
it('allows dispatch to a terminal after previous dispatch completes', () => {
const d = createDb()
const t1 = d.createTask({ spec: 'first' })
const t2 = d.createTask({ spec: 'second' })
const ctx1 = d.createDispatchContext(t1.id, 'term_worker')
d.completeDispatch(ctx1.id)
expect(() => d.createDispatchContext(t2.id, 'term_worker')).not.toThrow()
})
it('getDispatchContext returns latest for a task', () => {
const d = createDb()
const task = d.createTask({ spec: 'work' })
const ctx = d.createDispatchContext(task.id, 'term_a')
const found = d.getDispatchContext(task.id)
expect(found?.id).toBe(ctx.id)
})
it('getDispatchContext uses insertion order when timestamps tie', () => {
const d = createDb()
const task = d.createTask({ spec: 'work' })
const ctx1 = d.createDispatchContext(task.id, 'term_a')
d.failDispatch(ctx1.id, 'retry')
const ctx2 = d.createDispatchContext(task.id, 'term_a')
expect(d.getDispatchContext(task.id)?.id).toBe(ctx2.id)
})
it('getActiveDispatchForTerminal returns active dispatch', () => {
const d = createDb()
const task = d.createTask({ spec: 'work' })
d.createDispatchContext(task.id, 'term_a')
const active = d.getActiveDispatchForTerminal('term_a')
expect(active?.task_id).toBe(task.id)
expect(d.getActiveDispatchForTerminal('term_b')).toBeUndefined()
})
it('circuit breaker trips after 3 failures', () => {
const d = createDb()
const task = d.createTask({ spec: 'flaky' })
const ctx = d.createDispatchContext(task.id, 'term_a')
const after1 = d.failDispatch(ctx.id, 'timeout')
expect(after1?.failure_count).toBe(1)
expect(after1?.status).toBe('failed')
expect(d.getTask(task.id)?.status).toBe('ready')
const ctx2 = d.createDispatchContext(task.id, 'term_a')
const after2 = d.failDispatch(ctx2.id, 'timeout')
expect(after2?.failure_count).toBe(2)
expect(after2?.status).toBe('failed')
const ctx3 = d.createDispatchContext(task.id, 'term_a')
const after3 = d.failDispatch(ctx3.id, 'timeout')
expect(after3?.failure_count).toBe(3)
expect(after3?.status).toBe('circuit_broken')
expect(d.getTask(task.id)?.status).toBe('failed')
})
it('completeDispatch sets completed_at', () => {
const d = createDb()
const task = d.createTask({ spec: 'work' })
const ctx = d.createDispatchContext(task.id, 'term_a')
d.completeDispatch(ctx.id)
const updated = d.getDispatchContext(task.id)
expect(updated?.status).toBe('completed')
expect(updated?.completed_at).toBeTruthy()
})
})
describe('decision gates', () => {
it('creates a gate and blocks the task', () => {
const d = createDb()
const task = d.createTask({ spec: 'needs approval' })
d.createDispatchContext(task.id, 'term_a')
const gate = d.createGate({
taskId: task.id,
question: 'Proceed?',
options: ['yes', 'no']
})
expect(gate.id).toMatch(/^gate_/)
expect(gate.task_id).toBe(task.id)
expect(gate.status).toBe('pending')
expect(JSON.parse(gate.options)).toEqual(['yes', 'no'])
const updated = d.getTask(task.id)
expect(updated?.status).toBe('blocked')
expect(d.getActiveDispatchForTerminal('term_a')).toBeUndefined()
})
it('resolves a gate and unblocks the task', () => {
const d = createDb()
const task = d.createTask({ spec: 'work' })
const gate = d.createGate({ taskId: task.id, question: 'ok?' })
const resolved = d.resolveGate(gate.id, 'yes')
expect(resolved?.status).toBe('resolved')
expect(resolved?.resolution).toBe('yes')
const updated = d.getTask(task.id)
expect(updated?.status).toBe('ready')
})
it('times out a gate', () => {
const d = createDb()
const task = d.createTask({ spec: 'work' })
const gate = d.createGate({ taskId: task.id, question: 'ok?' })
const timedOut = d.timeoutGate(gate.id)
expect(timedOut?.status).toBe('timeout')
})
it('lists gates with filters', () => {
const d = createDb()
const t1 = d.createTask({ spec: 'a' })
const t2 = d.createTask({ spec: 'b' })
d.createGate({ taskId: t1.id, question: 'q1' })
const g2 = d.createGate({ taskId: t2.id, question: 'q2' })
d.resolveGate(g2.id, 'done')
expect(d.listGates()).toHaveLength(2)
expect(d.listGates({ status: 'pending' })).toHaveLength(1)
expect(d.listGates({ taskId: t1.id })).toHaveLength(1)
expect(d.listGates({ taskId: t2.id, status: 'resolved' })).toHaveLength(1)
})
it('returns undefined for nonexistent gate', () => {
const d = createDb()
expect(d.resolveGate('gate_fake', 'yes')).toBeUndefined()
})
})
describe('coordinator runs', () => {
it('creates and retrieves a coordinator run', () => {
const d = createDb()
const run = d.createCoordinatorRun({
spec: 'build feature',
coordinatorHandle: 'coord',
pollIntervalMs: 1000
})
expect(run.id).toMatch(/^run_/)
expect(run.status).toBe('running')
expect(run.coordinator_handle).toBe('coord')
expect(run.poll_interval_ms).toBe(1000)
})
it('updates coordinator run status', () => {
const d = createDb()
const run = d.createCoordinatorRun({
spec: 'work',
coordinatorHandle: 'coord'
})
const updated = d.updateCoordinatorRun(run.id, 'completed')
expect(updated?.status).toBe('completed')
expect(updated?.completed_at).not.toBeNull()
})
it('finds active coordinator run', () => {
const d = createDb()
expect(d.getActiveCoordinatorRun()).toBeUndefined()
const run = d.createCoordinatorRun({
spec: 'work',
coordinatorHandle: 'coord'
})
expect(d.getActiveCoordinatorRun()?.id).toBe(run.id)
d.updateCoordinatorRun(run.id, 'completed')
expect(d.getActiveCoordinatorRun()).toBeUndefined()
})
})
describe('lifecycle', () => {
it('resetAll clears all tables', () => {
const d = createDb()
d.insertMessage({ from: 'a', to: 'b', subject: 'test' })
d.createTask({ spec: 'work' })
d.resetAll()
expect(d.getInbox()).toHaveLength(0)
expect(d.listTasks()).toHaveLength(0)
})
it('resetMessages clears only messages', () => {
const d = createDb()
d.insertMessage({ from: 'a', to: 'b', subject: 'test' })
d.createTask({ spec: 'work' })
d.resetMessages()
expect(d.getInbox()).toHaveLength(0)
expect(d.listTasks()).toHaveLength(1)
})
it('resetTasks clears tasks and dispatch contexts', () => {
const d = createDb()
d.insertMessage({ from: 'a', to: 'b', subject: 'test' })
const task = d.createTask({ spec: 'work' })
d.createDispatchContext(task.id, 'term_a')
d.resetTasks()
expect(d.getInbox()).toHaveLength(1)
expect(d.listTasks()).toHaveLength(0)
})
})
})

View File

@ -0,0 +1,561 @@
/* eslint-disable max-lines -- Why: the orchestration DB keeps schema creation, message CRUD, task DAG resolution, and dispatch context management in one class so transactional invariants (e.g. promoteReadyTasks running inside the same writer as updateTaskStatus) are enforced by locality. */
import Database from 'better-sqlite3'
import { randomBytes } from 'crypto'
import type {
MessageType,
MessagePriority,
TaskStatus,
DispatchStatus,
GateStatus,
CoordinatorStatus,
MessageRow,
TaskRow,
DispatchContextRow,
DecisionGateRow,
CoordinatorRun
} from './types'
export type {
MessageType,
MessagePriority,
TaskStatus,
DispatchStatus,
GateStatus,
CoordinatorStatus,
MessageRow,
TaskRow,
DispatchContextRow,
DecisionGateRow,
CoordinatorRun
}
function generateId(prefix: string): string {
return `${prefix}_${randomBytes(6).toString('hex')}`
}
export class OrchestrationDb {
private db: Database.Database
constructor(dbPath: string | ':memory:') {
this.db = new Database(dbPath)
this.db.pragma('journal_mode = WAL')
this.db.pragma('synchronous = NORMAL')
this.db.pragma('busy_timeout = 5000')
this.createTables()
}
private createTables(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT NOT NULL,
from_handle TEXT NOT NULL,
to_handle TEXT NOT NULL,
subject TEXT NOT NULL,
body TEXT NOT NULL DEFAULT '',
type TEXT NOT NULL DEFAULT 'status'
CHECK(type IN (
'status', 'dispatch', 'worker_done', 'merge_ready',
'escalation', 'handoff', 'decision_gate'
)),
priority TEXT NOT NULL DEFAULT 'normal'
CHECK(priority IN ('normal', 'high', 'urgent')),
thread_id TEXT,
payload TEXT,
read INTEGER NOT NULL DEFAULT 0,
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_id ON messages(id);
CREATE INDEX IF NOT EXISTS idx_inbox ON messages(to_handle, read);
CREATE INDEX IF NOT EXISTS idx_thread ON messages(thread_id);
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
parent_id TEXT,
spec TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN (
'pending', 'ready', 'dispatched',
'completed', 'failed', 'blocked'
)),
deps TEXT NOT NULL DEFAULT '[]',
result TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_id);
CREATE TABLE IF NOT EXISTS dispatch_contexts (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
assignee_handle TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')),
failure_count INTEGER NOT NULL DEFAULT 0,
last_failure TEXT,
dispatched_at TEXT,
completed_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_dispatch_task ON dispatch_contexts(task_id);
CREATE INDEX IF NOT EXISTS idx_dispatch_status ON dispatch_contexts(status);
CREATE TABLE IF NOT EXISTS decision_gates (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
question TEXT NOT NULL,
options TEXT NOT NULL DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'resolved', 'timeout')),
resolution TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
resolved_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_gates_task ON decision_gates(task_id);
CREATE INDEX IF NOT EXISTS idx_gates_status ON decision_gates(status);
CREATE TABLE IF NOT EXISTS coordinator_runs (
id TEXT PRIMARY KEY,
spec TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'idle'
CHECK(status IN ('idle', 'running', 'completed', 'failed')),
coordinator_handle TEXT NOT NULL,
poll_interval_ms INTEGER NOT NULL DEFAULT 2000,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
completed_at TEXT
);
`)
}
// ── Messages ──
insertMessage(msg: {
from: string
to: string
subject: string
body?: string
type?: MessageType
priority?: MessagePriority
threadId?: string
payload?: string
}): MessageRow {
const id = generateId('msg')
const stmt = this.db.prepare(`
INSERT INTO messages (id, from_handle, to_handle, subject, body, type, priority, thread_id, payload)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`)
stmt.run(
id,
msg.from,
msg.to,
msg.subject,
msg.body ?? '',
msg.type ?? 'status',
msg.priority ?? 'normal',
msg.threadId ?? null,
msg.payload ?? null
)
return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow
}
getUnreadMessages(toHandle: string, types?: MessageType[]): MessageRow[] {
if (types && types.length > 0) {
const placeholders = types.map(() => '?').join(',')
return this.db
.prepare(
`SELECT * FROM messages WHERE to_handle = ? AND read = 0 AND type IN (${placeholders}) ORDER BY sequence`
)
.all(toHandle, ...types) as MessageRow[]
}
return this.db
.prepare('SELECT * FROM messages WHERE to_handle = ? AND read = 0 ORDER BY sequence')
.all(toHandle) as MessageRow[]
}
getAllMessages(toHandle: string, limit = 20): MessageRow[] {
return this.db
.prepare('SELECT * FROM messages WHERE to_handle = ? ORDER BY sequence DESC LIMIT ?')
.all(toHandle, limit) as MessageRow[]
}
getMessageById(id: string): MessageRow | undefined {
return this.db.prepare('SELECT * FROM messages WHERE id = ?').get(id) as MessageRow | undefined
}
markAsRead(ids: string[]): void {
if (ids.length === 0) {
return
}
const placeholders = ids.map(() => '?').join(',')
this.db.prepare(`UPDATE messages SET read = 1 WHERE id IN (${placeholders})`).run(...ids)
}
getInbox(limit = 20): MessageRow[] {
return this.db
.prepare('SELECT * FROM messages ORDER BY sequence DESC LIMIT ?')
.all(limit) as MessageRow[]
}
// ── Tasks ──
createTask(task: { spec: string; deps?: string[]; parentId?: string }): TaskRow {
const id = generateId('task')
const depsJson = JSON.stringify(task.deps ?? [])
const hasDeps = (task.deps ?? []).length > 0
const status: TaskStatus = hasDeps ? 'pending' : 'ready'
this.db
.prepare('INSERT INTO tasks (id, parent_id, spec, status, deps) VALUES (?, ?, ?, ?, ?)')
.run(id, task.parentId ?? null, task.spec, status, depsJson)
return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow
}
getTask(id: string): TaskRow | undefined {
return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow | undefined
}
listTasks(filter?: { status?: TaskStatus; ready?: boolean }): TaskRow[] {
if (filter?.ready) {
return this.db
.prepare("SELECT * FROM tasks WHERE status = 'ready' ORDER BY created_at")
.all() as TaskRow[]
}
if (filter?.status) {
return this.db
.prepare('SELECT * FROM tasks WHERE status = ? ORDER BY created_at')
.all(filter.status) as TaskRow[]
}
return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[]
}
updateTaskStatus(id: string, status: TaskStatus, result?: string): TaskRow | undefined {
const completedAt =
status === 'completed' || status === 'failed' ? new Date().toISOString() : null
this.db
.prepare(
'UPDATE tasks SET status = ?, result = COALESCE(?, result), completed_at = COALESCE(?, completed_at) WHERE id = ?'
)
.run(status, result ?? null, completedAt, id)
if (status === 'completed') {
this.promoteReadyTasks(id)
this.completeActiveDispatchForTask(id)
}
return this.getTask(id)
}
// Why: when a task completes, check if any pending tasks that depended on it
// now have all deps satisfied. If so, promote them to 'ready'. This is the
// DAG resolution step — it runs synchronously inside the same transaction as
// the status update, so there's no window where a task is completable but its
// children haven't been promoted.
private promoteReadyTasks(completedTaskId: string): void {
const candidates = this.db
.prepare("SELECT * FROM tasks WHERE status = 'pending'")
.all() as TaskRow[]
for (const task of candidates) {
const deps: string[] = JSON.parse(task.deps)
if (!deps.includes(completedTaskId)) {
continue
}
const allDepsCompleted = deps.every((depId) => {
const dep = this.getTask(depId)
return dep?.status === 'completed'
})
if (allDepsCompleted) {
this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(task.id)
}
}
}
// ── Dispatch Contexts ──
createDispatchContext(taskId: string, assigneeHandle: string): DispatchContextRow {
const task = this.getTask(taskId)
if (!task) {
throw new Error(`Task not found: ${taskId}`)
}
if (task.status !== 'ready') {
throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`)
}
const existing = this.db
.prepare(
"SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched')"
)
.get(assigneeHandle) as DispatchContextRow | undefined
if (existing) {
throw new Error(
`Terminal ${assigneeHandle} already has an active dispatch (${existing.id} for task ${existing.task_id})`
)
}
// Carry forward failure_count from prior contexts so the circuit breaker
// accumulates across retries for the same task.
const prior = this.db
.prepare('SELECT MAX(failure_count) as max_failures FROM dispatch_contexts WHERE task_id = ?')
.get(taskId) as { max_failures: number | null } | undefined
const priorFailures = prior?.max_failures ?? 0
const id = generateId('ctx')
this.db
.prepare(
`INSERT INTO dispatch_contexts (id, task_id, assignee_handle, status, failure_count, dispatched_at)
VALUES (?, ?, ?, 'dispatched', ?, datetime('now'))`
)
.run(id, taskId, assigneeHandle, priorFailures)
this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId)
return this.db
.prepare('SELECT * FROM dispatch_contexts WHERE id = ?')
.get(id) as DispatchContextRow
}
getDispatchContext(taskId: string): DispatchContextRow | undefined {
return this.db
.prepare('SELECT * FROM dispatch_contexts WHERE task_id = ? ORDER BY rowid DESC LIMIT 1')
.get(taskId) as DispatchContextRow | undefined
}
getActiveDispatchForTerminal(handle: string): DispatchContextRow | undefined {
return this.db
.prepare(
"SELECT * FROM dispatch_contexts WHERE assignee_handle = ? AND status IN ('pending', 'dispatched') LIMIT 1"
)
.get(handle) as DispatchContextRow | undefined
}
completeDispatch(ctxId: string): void {
this.db
.prepare(
"UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now') WHERE id = ?"
)
.run(ctxId)
}
completeActiveDispatchForTask(taskId: string): void {
const active = this.db
.prepare(
"SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1"
)
.get(taskId) as DispatchContextRow | undefined
if (active) {
this.completeDispatch(active.id)
}
}
failActiveDispatchForTask(taskId: string, error: string): DispatchContextRow | undefined {
const active = this.db
.prepare(
"SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1"
)
.get(taskId) as DispatchContextRow | undefined
return active ? this.failDispatch(active.id, error) : undefined
}
failDispatch(ctxId: string, error: string): DispatchContextRow | undefined {
const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
| DispatchContextRow
| undefined
if (!ctx) {
return undefined
}
const newFailureCount = ctx.failure_count + 1
const newStatus: DispatchStatus = newFailureCount >= 3 ? 'circuit_broken' : 'failed'
this.db
.prepare(
'UPDATE dispatch_contexts SET status = ?, failure_count = ?, last_failure = ? WHERE id = ?'
)
.run(newStatus, newFailureCount, error, ctxId)
// Why: set the task back to 'ready' (not 'pending') so the coordinator can
// re-dispatch it on the next tick. The task's deps are already satisfied —
// setting it to 'pending' would strand it since promoteReadyTasks only runs
// when a dep completes.
const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready'
this.db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(taskStatus, ctx.task_id)
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
| DispatchContextRow
| undefined
}
// ── Decision Gates ──
createGate(gate: { taskId: string; question: string; options?: string[] }): DecisionGateRow {
const id = generateId('gate')
const optionsJson = JSON.stringify(gate.options ?? [])
this.db
.prepare('INSERT INTO decision_gates (id, task_id, question, options) VALUES (?, ?, ?, ?)')
.run(id, gate.taskId, gate.question, optionsJson)
this.completeActiveDispatchForTask(gate.taskId)
this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId)
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as DecisionGateRow
}
resolveGate(gateId: string, resolution: string): DecisionGateRow | undefined {
const gate = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
| DecisionGateRow
| undefined
if (!gate) {
return undefined
}
this.db
.prepare(
"UPDATE decision_gates SET status = 'resolved', resolution = ?, resolved_at = datetime('now') WHERE id = ?"
)
.run(resolution, gateId)
// Why: unblock the task so the coordinator can re-dispatch it with the
// resolution context. Setting to 'ready' rather than the previous status
// because the worker needs to be re-engaged with the decision outcome.
this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id)
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
| DecisionGateRow
| undefined
}
timeoutGate(gateId: string): DecisionGateRow | undefined {
this.db
.prepare(
"UPDATE decision_gates SET status = 'timeout', resolved_at = datetime('now') WHERE id = ?"
)
.run(gateId)
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
| DecisionGateRow
| undefined
}
listGates(filter?: { taskId?: string; status?: GateStatus }): DecisionGateRow[] {
if (filter?.taskId && filter?.status) {
return this.db
.prepare(
'SELECT * FROM decision_gates WHERE task_id = ? AND status = ? ORDER BY created_at'
)
.all(filter.taskId, filter.status) as DecisionGateRow[]
}
if (filter?.taskId) {
return this.db
.prepare('SELECT * FROM decision_gates WHERE task_id = ? ORDER BY created_at')
.all(filter.taskId) as DecisionGateRow[]
}
if (filter?.status) {
return this.db
.prepare('SELECT * FROM decision_gates WHERE status = ? ORDER BY created_at')
.all(filter.status) as DecisionGateRow[]
}
return this.db
.prepare('SELECT * FROM decision_gates ORDER BY created_at')
.all() as DecisionGateRow[]
}
getGate(id: string): DecisionGateRow | undefined {
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as
| DecisionGateRow
| undefined
}
// ── Coordinator Runs ──
createCoordinatorRun(run: {
spec: string
coordinatorHandle: string
pollIntervalMs?: number
}): CoordinatorRun {
const id = generateId('run')
this.db
.prepare(
"INSERT INTO coordinator_runs (id, spec, status, coordinator_handle, poll_interval_ms) VALUES (?, ?, 'running', ?, ?)"
)
.run(id, run.spec, run.coordinatorHandle, run.pollIntervalMs ?? 2000)
return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as CoordinatorRun
}
getCoordinatorRun(id: string): CoordinatorRun | undefined {
return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as
| CoordinatorRun
| undefined
}
updateCoordinatorRun(id: string, status: CoordinatorStatus): CoordinatorRun | undefined {
const completedAt =
status === 'completed' || status === 'failed' ? new Date().toISOString() : null
this.db
.prepare(
'UPDATE coordinator_runs SET status = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?'
)
.run(status, completedAt, id)
return this.getCoordinatorRun(id)
}
getActiveCoordinatorRun(): CoordinatorRun | undefined {
return this.db
.prepare(
"SELECT * FROM coordinator_runs WHERE status = 'running' ORDER BY created_at DESC LIMIT 1"
)
.get() as CoordinatorRun | undefined
}
// ── Queries for Coordinator ──
getIdleTerminals(excludeHandles: string[] = []): string[] {
// Why: returns terminal handles that have no active dispatch, so the
// coordinator knows which terminals are available for new task assignments.
const active = this.db
.prepare(
"SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')"
)
.all() as { assignee_handle: string }[]
const busyHandles = new Set(active.map((r) => r.assignee_handle))
for (const h of excludeHandles) {
busyHandles.add(h)
}
// Return handles from message history that aren't busy
const allHandles = this.db
.prepare(
'SELECT DISTINCT to_handle FROM messages UNION SELECT DISTINCT from_handle FROM messages'
)
.all() as { to_handle: string }[]
return [...new Set(allHandles.map((r) => r.to_handle))].filter((h) => !busyHandles.has(h))
}
// ── Lifecycle ──
resetAll(): void {
this.db.exec('DELETE FROM coordinator_runs')
this.db.exec('DELETE FROM decision_gates')
this.db.exec('DELETE FROM dispatch_contexts')
this.db.exec('DELETE FROM tasks')
this.db.exec('DELETE FROM messages')
}
resetTasks(): void {
this.db.exec('DELETE FROM coordinator_runs')
this.db.exec('DELETE FROM decision_gates')
this.db.exec('DELETE FROM dispatch_contexts')
this.db.exec('DELETE FROM tasks')
}
resetMessages(): void {
this.db.exec('DELETE FROM messages')
}
close(): void {
this.db.close()
}
}

View File

@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { formatMessageBanner, formatMessagesForInjection } from './formatter'
import type { MessageRow } from './types'
function makeMessage(overrides: Partial<MessageRow> = {}): MessageRow {
return {
id: 'msg_test1',
from_handle: 'term_abc123',
to_handle: 'term_coord',
subject: 'Auth API implementation complete',
body: 'All endpoints implemented. Tests passing.',
type: 'worker_done',
priority: 'normal',
thread_id: null,
payload: null,
read: 0,
sequence: 1,
created_at: '2026-01-01T00:00:00Z',
...overrides
}
}
describe('formatMessageBanner', () => {
it('formats a normal priority message without a priority tag', () => {
const banner = formatMessageBanner(makeMessage())
expect(banner).toContain('From: TERM_ABC123 (term_abc123)')
expect(banner).toContain('(worker_done)')
expect(banner).not.toContain('[URGENT]')
expect(banner).not.toContain('[HIGH]')
})
it('includes [HIGH] tag for high priority', () => {
const banner = formatMessageBanner(makeMessage({ priority: 'high' }))
expect(banner).toContain('[HIGH]')
})
it('includes [URGENT] tag for urgent priority', () => {
const banner = formatMessageBanner(makeMessage({ priority: 'urgent' }))
expect(banner).toContain('[URGENT]')
})
it('includes body after subject', () => {
const banner = formatMessageBanner(makeMessage({ body: 'Some details here' }))
const lines = banner.split('\n')
const subjectIdx = lines.findIndex((l) => l.startsWith('Subject:'))
const bodyIdx = lines.indexOf('Some details here')
expect(subjectIdx).toBeGreaterThanOrEqual(0)
expect(bodyIdx).toBeGreaterThan(subjectIdx)
})
it('omits body line when body is empty', () => {
const banner = formatMessageBanner(makeMessage({ body: '' }))
const lines = banner.split('\n')
// Subject line should be immediately followed by Reply hint (no empty body line)
const subjectIdx = lines.findIndex((l) => l.startsWith('Subject:'))
expect(lines[subjectIdx + 1]).toMatch(/^\[Reply:/)
})
it('includes payload when present', () => {
const payload = '{"taskId":"task_1","exitCode":0}'
const banner = formatMessageBanner(makeMessage({ payload }))
expect(banner).toContain(`[Payload: ${payload}]`)
})
it('omits payload line when payload is null', () => {
const banner = formatMessageBanner(makeMessage({ payload: null }))
expect(banner).not.toContain('[Payload:')
})
it('includes reply hint with message ID', () => {
const banner = formatMessageBanner(makeMessage({ id: 'msg_xyz789' }))
expect(banner).toContain('[Reply: orca orchestration reply --id msg_xyz789 --body "..."]')
})
it('ends with a separator line', () => {
const banner = formatMessageBanner(makeMessage())
const lines = banner.split('\n')
expect(lines.at(-1)).toMatch(/^─+$/)
})
})
describe('formatMessagesForInjection', () => {
it('returns empty string for empty array', () => {
expect(formatMessagesForInjection([])).toBe('')
})
it('wraps multiple banners with orchestration messages header', () => {
const messages = [makeMessage({ id: 'msg_1' }), makeMessage({ id: 'msg_2' })]
const result = formatMessagesForInjection(messages)
expect(result).toContain('--- Orchestration Messages (2) ---')
expect(result).toContain('msg_1')
expect(result).toContain('msg_2')
expect(result).toMatch(/\n---\n$/)
})
it('separates multiple banners with blank lines', () => {
const messages = [makeMessage({ id: 'msg_a' }), makeMessage({ id: 'msg_b' })]
const result = formatMessagesForInjection(messages)
// Two banners should be separated by \n\n
const bannerA = formatMessageBanner(messages[0])
const bannerB = formatMessageBanner(messages[1])
expect(result).toContain(`${bannerA}\n\n${bannerB}`)
})
})

View File

@ -0,0 +1,43 @@
import type { MessageRow } from './types'
const BANNER_WIDTH = 60
const SEPARATOR = '─'.repeat(BANNER_WIDTH)
// Why: rich message banners help agents (and humans reading terminal output)
// quickly parse message metadata. Priority indicators surface urgent messages
// visually. The reply hint reduces friction for agent-to-agent responses
// (Section 4.8).
export function formatMessageBanner(msg: MessageRow): string {
const priorityTag =
msg.priority === 'urgent' ? ' [URGENT]' : msg.priority === 'high' ? ' [HIGH]' : ''
const senderName = msg.from_handle.toUpperCase()
const header = `──── From: ${senderName} (${msg.from_handle})${priorityTag} (${msg.type}) ────`
const lines: string[] = [header]
lines.push(`Subject: ${msg.subject}`)
if (msg.body) {
lines.push(msg.body)
}
if (msg.payload) {
lines.push(`[Payload: ${msg.payload}]`)
}
lines.push(`[Reply: orca orchestration reply --id ${msg.id} --body "..."]`)
lines.push(SEPARATOR)
return lines.join('\n')
}
// Why: grouping multiple banners under a single wrapper line lets agents detect
// the message block boundary and parse each banner individually.
export function formatMessagesForInjection(messages: MessageRow[]): string {
if (messages.length === 0) {
return ''
}
const banners = messages.map(formatMessageBanner).join('\n\n')
return `\n--- Orchestration Messages (${messages.length}) ---\n${banners}\n---\n`
}

View File

@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest'
import { isGroupAddress, resolveGroupAddress } from './groups'
import type { RuntimeTerminalSummary } from '../../../shared/runtime-types'
function makeSummary(
handle: string,
opts: Partial<RuntimeTerminalSummary> = {}
): RuntimeTerminalSummary {
return {
handle,
worktreeId: opts.worktreeId ?? 'wt_default',
worktreePath: opts.worktreePath ?? '/tmp/wt',
branch: opts.branch ?? 'main',
tabId: opts.tabId ?? 'tab_1',
leafId: opts.leafId ?? handle,
title: opts.title ?? null,
connected: opts.connected ?? true,
writable: opts.writable ?? true,
lastOutputAt: opts.lastOutputAt ?? null,
preview: opts.preview ?? ''
}
}
const noStatus = () => null
describe('isGroupAddress', () => {
it('returns true for @-prefixed addresses', () => {
expect(isGroupAddress('@all')).toBe(true)
expect(isGroupAddress('@idle')).toBe(true)
expect(isGroupAddress('@claude')).toBe(true)
expect(isGroupAddress('@worktree:wt_1')).toBe(true)
})
it('returns false for regular handles', () => {
expect(isGroupAddress('term_abc')).toBe(false)
expect(isGroupAddress('coordinator')).toBe(false)
expect(isGroupAddress('')).toBe(false)
})
})
describe('resolveGroupAddress', () => {
it('returns the address as-is for non-group addresses', () => {
const result = resolveGroupAddress('term_b', 'term_a', [], noStatus)
expect(result).toEqual(['term_b'])
})
describe('@all', () => {
it('returns all terminals except sender', () => {
const terminals = [makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]
const result = resolveGroupAddress('@all', 'term_a', terminals, noStatus)
expect(result).toEqual(['term_b', 'term_c'])
})
it('returns empty when sender is the only terminal', () => {
const terminals = [makeSummary('term_a')]
const result = resolveGroupAddress('@all', 'term_a', terminals, noStatus)
expect(result).toEqual([])
})
})
describe('@idle', () => {
it('returns only idle terminals', () => {
const terminals = [makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')]
const getStatus = (h: string) => (h === 'term_b' ? 'idle' : 'busy')
const result = resolveGroupAddress('@idle', 'term_a', terminals, getStatus)
expect(result).toEqual(['term_b'])
})
it('excludes sender even if idle', () => {
const terminals = [makeSummary('term_a'), makeSummary('term_b')]
const getStatus = () => 'idle'
const result = resolveGroupAddress('@idle', 'term_a', terminals, getStatus)
expect(result).toEqual(['term_b'])
})
})
describe('@worktree:<id>', () => {
it('returns terminals in the specified worktree', () => {
const terminals = [
makeSummary('term_a', { worktreeId: 'wt_1' }),
makeSummary('term_b', { worktreeId: 'wt_1' }),
makeSummary('term_c', { worktreeId: 'wt_2' })
]
const result = resolveGroupAddress('@worktree:wt_1', 'term_a', terminals, noStatus)
expect(result).toEqual(['term_b'])
})
it('returns empty for nonexistent worktree', () => {
const terminals = [makeSummary('term_a', { worktreeId: 'wt_1' })]
const result = resolveGroupAddress('@worktree:wt_99', 'term_a', terminals, noStatus)
expect(result).toEqual([])
})
})
describe('agent name groups', () => {
it('matches @claude by terminal title', () => {
const terminals = [
makeSummary('term_a', { title: 'Claude Code' }),
makeSummary('term_b', { title: 'Claude Code' }),
makeSummary('term_c', { title: 'Codex CLI' })
]
const result = resolveGroupAddress('@claude', 'term_a', terminals, noStatus)
expect(result).toEqual(['term_b'])
})
it('matches @codex by terminal title', () => {
const terminals = [
makeSummary('term_a', { title: 'Codex CLI' }),
makeSummary('term_b', { title: 'Codex CLI' })
]
const result = resolveGroupAddress('@codex', 'term_a', terminals, noStatus)
expect(result).toEqual(['term_b'])
})
it('is case-insensitive for group address', () => {
const terminals = [makeSummary('term_a'), makeSummary('term_b', { title: 'Claude Code' })]
const result = resolveGroupAddress('@Claude', 'term_a', terminals, noStatus)
expect(result).toEqual(['term_b'])
})
})
describe('unknown groups', () => {
it('returns empty for unrecognized group', () => {
const terminals = [makeSummary('term_a'), makeSummary('term_b')]
const result = resolveGroupAddress('@unknown', 'term_a', terminals, noStatus)
expect(result).toEqual([])
})
})
})

View File

@ -0,0 +1,71 @@
import type { RuntimeTerminalSummary } from '../../../shared/runtime-types'
// Why: group addresses enable broadcast messaging to logical groups of agents.
// Resolution is done at send-time: one message record per recipient, same thread_id,
// so each recipient gets their own read-tracking (Section 4.5).
const AGENT_NAME_GROUPS = ['claude', 'codex', 'opencode', 'gemini'] as const
export type GroupAddress =
| '@all'
| '@idle'
| `@${(typeof AGENT_NAME_GROUPS)[number]}`
| `@worktree:${string}`
export function isGroupAddress(to: string): boolean {
return to.startsWith('@')
}
export function resolveGroupAddress(
to: string,
senderHandle: string,
terminals: RuntimeTerminalSummary[],
getAgentStatus: (handle: string) => string | null
): string[] {
if (!isGroupAddress(to)) {
return [to]
}
const group = to.toLowerCase()
if (group === '@all') {
// Why: @all broadcasts to every terminal except the sender to avoid self-delivery loops.
return terminals.map((t) => t.handle).filter((h) => h !== senderHandle)
}
if (group === '@idle') {
// Why: @idle targets only agents whose TUI reports idle status, useful for
// dispatching work to available agents without interrupting busy ones.
return terminals
.filter((t) => t.handle !== senderHandle && getAgentStatus(t.handle) === 'idle')
.map((t) => t.handle)
}
// @worktree:<id> — all handles in a specific worktree
if (group.startsWith('@worktree:')) {
const worktreeId = to.slice('@worktree:'.length)
return terminals
.filter((t) => t.handle !== senderHandle && t.worktreeId === worktreeId)
.map((t) => t.handle)
}
// Why: agent-name groups (@claude, @codex, etc.) match by terminal title so
// the sender can address all instances of a particular agent type without
// knowing their handles.
const agentName = group.slice(1) // remove @
if ((AGENT_NAME_GROUPS as readonly string[]).includes(agentName)) {
return terminals
.filter((t) => {
if (t.handle === senderHandle) {
return false
}
const title = (t.title ?? '').toLowerCase()
return title.includes(agentName)
})
.map((t) => t.handle)
}
// Why: unknown groups resolve to empty rather than throwing so callers can
// distinguish "valid group, no current members" from programming errors.
return []
}

View File

@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { buildDispatchPreamble } from './preamble'
describe('buildDispatchPreamble', () => {
it('substitutes template variables', () => {
const result = buildDispatchPreamble({
taskId: 'task_abc123',
taskSpec: 'Implement the login form',
coordinatorHandle: 'term_coord'
})
expect(result).toContain('task_abc123')
expect(result).toContain('term_coord')
expect(result).toContain('Implement the login form')
expect(result).not.toContain('{{')
})
it('includes worker_done command', () => {
const result = buildDispatchPreamble({
taskId: 'task_x',
taskSpec: 'do stuff',
coordinatorHandle: 'term_c'
})
expect(result).toContain('worker_done')
expect(result).toContain('orchestration send')
expect(result).toContain('orchestration check')
})
it('includes the task spec after separator', () => {
const result = buildDispatchPreamble({
taskId: 'task_x',
taskSpec: 'refactor the auth module',
coordinatorHandle: 'term_c'
})
expect(result).toContain('--- TASK ---')
expect(result).toContain('refactor the auth module')
})
it('uses orca CLI by default when devMode is not set', () => {
const result = buildDispatchPreamble({
taskId: 'task_x',
taskSpec: 'do stuff',
coordinatorHandle: 'term_c'
})
expect(result).toContain('orca orchestration send')
expect(result).toContain('orca orchestration check')
})
it('uses orca-dev CLI when devMode is true', () => {
const result = buildDispatchPreamble({
taskId: 'task_x',
taskSpec: 'do stuff',
coordinatorHandle: 'term_c',
devMode: true
})
expect(result).toContain('orca-dev orchestration send')
expect(result).toContain('orca-dev orchestration check')
// Ensure no bare "orca " (without -dev) appears as a CLI command.
// We split on "orca-dev" first so those occurrences don't produce
// false positives, then check the remaining fragments.
const fragments = result.split('orca-dev')
for (const fragment of fragments) {
expect(fragment).not.toMatch(/orca orchestration/)
}
})
it('uses orca CLI when devMode is false', () => {
const result = buildDispatchPreamble({
taskId: 'task_x',
taskSpec: 'do stuff',
coordinatorHandle: 'term_c',
devMode: false
})
expect(result).toContain('orca orchestration send')
expect(result).toContain('orca orchestration check')
})
})

View File

@ -0,0 +1,41 @@
export type PreambleParams = {
taskId: string
taskSpec: string
coordinatorHandle: string
devMode?: boolean
}
// Why: the dispatch preamble teaches agents about Orca's CLI commands for
// structured communication. Agents don't need prior knowledge of Orca — they
// treat these as shell tools the same way they use git or npm.
export function buildDispatchPreamble(params: PreambleParams): string {
// Why: in dev mode, agents must use orca-dev to connect to the dev runtime's
// socket. Without this, agents inside the dev Electron app would call the
// production CLI and talk to the wrong Orca instance (Section 6.4).
const cli = params.devMode ? 'orca-dev' : 'orca'
return `You are working inside Orca, a multi-agent IDE. You have access to these
CLI commands for communicating with the coordinator:
# Report task completion (REQUIRED when done):
${cli} orchestration send --to ${params.coordinatorHandle} \\
--type worker_done --subject "Done" \\
--payload '{"taskId":"${params.taskId}","filesModified":[...]}'
# Report a blocker or failure:
${cli} orchestration send --to ${params.coordinatorHandle} \\
--type escalation --subject "Blocked: <reason>" \\
--body "<details>"
# Check for messages from the coordinator or other agents:
${cli} orchestration check
Your assigned task ID is: ${params.taskId}
When you finish your task, run the worker_done command above with the
list of files you modified. If you are blocked or need help, send an
escalation. Do not exit the session.
--- TASK ---
${params.taskSpec}`
}

View File

@ -0,0 +1,77 @@
export type MessageType =
| 'status'
| 'dispatch'
| 'worker_done'
| 'merge_ready'
| 'escalation'
| 'handoff'
| 'decision_gate'
export type MessagePriority = 'normal' | 'high' | 'urgent'
export type TaskStatus = 'pending' | 'ready' | 'dispatched' | 'completed' | 'failed' | 'blocked'
export type DispatchStatus = 'pending' | 'dispatched' | 'completed' | 'failed' | 'circuit_broken'
export type GateStatus = 'pending' | 'resolved' | 'timeout'
export type CoordinatorStatus = 'idle' | 'running' | 'completed' | 'failed'
export type MessageRow = {
id: string
from_handle: string
to_handle: string
subject: string
body: string
type: MessageType
priority: MessagePriority
thread_id: string | null
payload: string | null
read: number
sequence: number
created_at: string
}
export type TaskRow = {
id: string
parent_id: string | null
spec: string
status: TaskStatus
deps: string
result: string | null
created_at: string
completed_at: string | null
}
export type DispatchContextRow = {
id: string
task_id: string
assignee_handle: string | null
status: DispatchStatus
failure_count: number
last_failure: string | null
dispatched_at: string | null
completed_at: string | null
created_at: string
}
export type DecisionGateRow = {
id: string
task_id: string
question: string
options: string
status: GateStatus
resolution: string | null
created_at: string
resolved_at: string | null
}
export type CoordinatorRun = {
id: string
spec: string
status: CoordinatorStatus
coordinator_handle: string
poll_interval_ms: number
created_at: string
completed_at: string | null
}

View File

@ -5,6 +5,7 @@ import { WORKTREE_METHODS } from './worktree'
import { TERMINAL_METHODS } from './terminal'
import { BROWSER_CORE_METHODS } from './browser-core'
import { BROWSER_EXTRA_METHODS } from './browser-extras'
import { ORCHESTRATION_METHODS } from './orchestration'
// Why: a flat manifest keeps registration order explicit and provides one
// grep-point for "what methods does the RPC server expose?" — useful when
@ -15,5 +16,6 @@ export const ALL_RPC_METHODS: readonly RpcMethod[] = [
...WORKTREE_METHODS,
...TERMINAL_METHODS,
...BROWSER_CORE_METHODS,
...BROWSER_EXTRA_METHODS
...BROWSER_EXTRA_METHODS,
...ORCHESTRATION_METHODS
]

View File

@ -0,0 +1,154 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, requiredString } from '../schemas'
import type { GateStatus } from '../../orchestration/db'
import { Coordinator } from '../../orchestration/coordinator'
// Why: the coordinator instance is stored at module scope so orchestration.runStop
// can signal it to halt. Only one coordinator can run at a time (enforced by
// the DB's active-run check), so a single reference suffices.
let activeCoordinator: Coordinator | null = null
const RunParams = z.object({
spec: requiredString('Missing --spec'),
from: OptionalString,
pollIntervalMs: OptionalFiniteNumber,
maxConcurrent: OptionalFiniteNumber,
worktree: OptionalString
})
const RunStopParams = z.object({})
const GateCreateParams = z.object({
task: requiredString('Missing --task'),
question: requiredString('Missing --question'),
options: OptionalString
})
const GateResolveParams = z.object({
id: requiredString('Missing --id'),
resolution: requiredString('Missing --resolution')
})
const GateListParams = z.object({
task: OptionalString,
status: z.enum(['pending', 'resolved', 'timeout']).optional()
})
export const ORCHESTRATION_GATE_METHODS: RpcMethod[] = [
// Why: Section 4.12 — orchestration.run returns immediately with a run ID.
// The coordinator loop runs in the background; progress is queried via
// orchestration.taskList. This prevents the RPC call from blocking the
// CLI (or any caller) for the entire duration of the pipeline.
defineMethod({
name: 'orchestration.run',
params: RunParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const existing = db.getActiveCoordinatorRun()
if (existing) {
throw new Error(`Coordinator already running: ${existing.id}`)
}
const coordinatorHandle = params.from ?? 'coordinator'
const coordinator = new Coordinator(db, runtime, {
spec: params.spec,
coordinatorHandle,
pollIntervalMs: params.pollIntervalMs,
maxConcurrent: params.maxConcurrent,
worktree: params.worktree
})
activeCoordinator = coordinator
const run = db.createCoordinatorRun({
spec: params.spec,
coordinatorHandle,
pollIntervalMs: params.pollIntervalMs
})
// Why: fire-and-forget — the coordinator loop runs in the event loop
// background. Results are persisted to the DB; callers query via
// orchestration.taskList or orchestration.runStatus.
coordinator.runFromExistingRun(run.id).finally(() => {
if (activeCoordinator === coordinator) {
activeCoordinator = null
}
})
return { runId: run.id, status: 'running' }
}
}),
defineMethod({
name: 'orchestration.runStop',
params: RunStopParams,
handler: (_params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const run = db.getActiveCoordinatorRun()
if (!run) {
throw new Error('No active coordinator run')
}
if (activeCoordinator) {
activeCoordinator.stop()
activeCoordinator = null
}
return { runId: run.id, stopped: true }
}
}),
defineMethod({
name: 'orchestration.gateCreate',
params: GateCreateParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
let options: string[] | undefined
if (params.options) {
try {
const parsed = JSON.parse(params.options)
if (!Array.isArray(parsed) || !parsed.every((option) => typeof option === 'string')) {
throw new Error('not an array of strings')
}
options = parsed
} catch {
throw new Error('Invalid --options: must be a JSON array of strings')
}
}
const gate = db.createGate({
taskId: params.task,
question: params.question,
options
})
return { gate }
}
}),
defineMethod({
name: 'orchestration.gateResolve',
params: GateResolveParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const gate = db.resolveGate(params.id, params.resolution)
if (!gate) {
throw new Error(`Gate not found: ${params.id}`)
}
return { gate }
}
}),
defineMethod({
name: 'orchestration.gateList',
params: GateListParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const gates = db.listGates({
taskId: params.task,
status: params.status as GateStatus
})
return { gates, count: gates.length }
}
})
]

View File

@ -0,0 +1,656 @@
/* eslint-disable max-lines -- Why: orchestration tests share a mock runtime factory; splitting by method would duplicate 40 lines of setup per file without improving clarity. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_METHODS } from './orchestration'
import { buildRegistry, type RpcContext } from '../core'
import { OrchestrationDb } from '../../orchestration/db'
import { OrcaRuntimeService } from '../../orca-runtime'
import type { RuntimeTerminalSummary } from '../../../../shared/runtime-types'
describe('orchestration RPC methods', () => {
let db: OrchestrationDb
let runtime: OrcaRuntimeService
let ctx: RpcContext
function setup(): void {
db = new OrchestrationDb(':memory:')
runtime = new OrcaRuntimeService()
runtime.setOrchestrationDb(db)
ctx = { runtime }
}
afterEach(() => {
db?.close()
})
function findMethod(name: string) {
const method = ORCHESTRATION_METHODS.find((m) => m.name === name)
if (!method) {
throw new Error(`Method not found: ${name}`)
}
return method
}
async function call(name: string, params: Record<string, unknown>) {
const method = findMethod(name)
const parsed = method.params ? method.params.parse(params) : undefined
return method.handler(parsed, ctx)
}
it('registers all expected methods', () => {
const registry = buildRegistry(ORCHESTRATION_METHODS)
expect(registry.size).toBe(15)
expect(registry.has('orchestration.send')).toBe(true)
expect(registry.has('orchestration.check')).toBe(true)
expect(registry.has('orchestration.reply')).toBe(true)
expect(registry.has('orchestration.inbox')).toBe(true)
expect(registry.has('orchestration.taskCreate')).toBe(true)
expect(registry.has('orchestration.taskList')).toBe(true)
expect(registry.has('orchestration.taskUpdate')).toBe(true)
expect(registry.has('orchestration.dispatch')).toBe(true)
expect(registry.has('orchestration.dispatchShow')).toBe(true)
expect(registry.has('orchestration.run')).toBe(true)
expect(registry.has('orchestration.runStop')).toBe(true)
expect(registry.has('orchestration.gateCreate')).toBe(true)
expect(registry.has('orchestration.gateResolve')).toBe(true)
expect(registry.has('orchestration.gateList')).toBe(true)
expect(registry.has('orchestration.reset')).toBe(true)
})
describe('orchestration.send', () => {
it('sends a message', async () => {
setup()
vi.spyOn(runtime, 'deliverPendingMessagesForHandle').mockImplementation(() => {})
const result = (await call('orchestration.send', {
from: 'term_a',
to: 'term_b',
subject: 'hello'
})) as { message: { id: string; from_handle: string } }
expect(result.message.id).toMatch(/^msg_/)
expect(result.message.from_handle).toBe('term_a')
expect(runtime.deliverPendingMessagesForHandle).toHaveBeenCalledWith('term_b')
})
it('rejects missing --to', () => {
const method = findMethod('orchestration.send')
expect(() => method.params!.parse({ subject: 'hi' })).toThrow()
})
it('rejects missing --subject', () => {
const method = findMethod('orchestration.send')
expect(() => method.params!.parse({ to: 'b' })).toThrow()
})
it('rejects invalid enum values', () => {
const method = findMethod('orchestration.send')
expect(() => method.params!.parse({ to: 'b', subject: 'hi', type: 'typo' })).toThrow()
expect(() => method.params!.parse({ to: 'b', subject: 'hi', priority: 'medium' })).toThrow()
})
function makeSummary(
handle: string,
opts: Partial<RuntimeTerminalSummary> = {}
): RuntimeTerminalSummary {
return {
handle,
worktreeId: opts.worktreeId ?? 'wt_default',
worktreePath: opts.worktreePath ?? '/tmp/wt',
branch: opts.branch ?? 'main',
tabId: opts.tabId ?? 'tab_1',
leafId: opts.leafId ?? handle,
title: opts.title ?? null,
connected: opts.connected ?? true,
writable: opts.writable ?? true,
lastOutputAt: opts.lastOutputAt ?? null,
preview: opts.preview ?? ''
}
}
function setupWithTerminals(
terminals: RuntimeTerminalSummary[],
agentStatuses?: Record<string, string>
): void {
setup()
vi.spyOn(runtime, 'listTerminals').mockResolvedValue({
terminals,
totalCount: terminals.length,
truncated: false
})
vi.spyOn(runtime, 'getAgentStatusForHandle').mockImplementation(
(handle: string) => agentStatuses?.[handle] ?? null
)
}
it('fans out @all to all terminals except sender', async () => {
setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')])
const result = (await call('orchestration.send', {
from: 'term_a',
to: '@all',
subject: 'broadcast'
})) as { messages: { to_handle: string }[]; recipients: number }
expect(result.recipients).toBe(2)
expect(result.messages).toHaveLength(2)
const recipients = result.messages.map((m) => m.to_handle).sort()
expect(recipients).toEqual(['term_b', 'term_c'])
})
it('fans out @idle to only idle agents', async () => {
setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')], {
term_b: 'idle',
term_c: 'busy'
})
const result = (await call('orchestration.send', {
from: 'term_a',
to: '@idle',
subject: 'idle check'
})) as { messages: { to_handle: string }[]; recipients: number }
expect(result.recipients).toBe(1)
expect(result.messages[0].to_handle).toBe('term_b')
})
it('fans out agent name group (@claude) by title match', async () => {
setupWithTerminals([
makeSummary('term_a', { title: 'Claude Code' }),
makeSummary('term_b', { title: 'Claude Code' }),
makeSummary('term_c', { title: 'Codex' })
])
const result = (await call('orchestration.send', {
from: 'term_a',
to: '@claude',
subject: 'claude only'
})) as { messages: { to_handle: string }[]; recipients: number }
expect(result.recipients).toBe(1)
expect(result.messages[0].to_handle).toBe('term_b')
})
it('fans out @worktree:<id> to matching worktree', async () => {
setupWithTerminals([
makeSummary('term_a', { worktreeId: 'wt_1' }),
makeSummary('term_b', { worktreeId: 'wt_1' }),
makeSummary('term_c', { worktreeId: 'wt_2' })
])
const result = (await call('orchestration.send', {
from: 'term_a',
to: '@worktree:wt_1',
subject: 'worktree msg'
})) as { messages: { to_handle: string }[]; recipients: number }
expect(result.recipients).toBe(1)
expect(result.messages[0].to_handle).toBe('term_b')
})
it('shares thread_id across fan-out messages', async () => {
setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')])
const result = (await call('orchestration.send', {
from: 'term_a',
to: '@all',
subject: 'threaded',
threadId: 'my_thread'
})) as { messages: { thread_id: string }[] }
expect(result.messages[0].thread_id).toBe('my_thread')
expect(result.messages[1].thread_id).toBe('my_thread')
})
it('generates a shared thread_id when none provided', async () => {
setupWithTerminals([makeSummary('term_a'), makeSummary('term_b'), makeSummary('term_c')])
const result = (await call('orchestration.send', {
from: 'term_a',
to: '@all',
subject: 'auto thread'
})) as { messages: { thread_id: string }[] }
expect(result.messages[0].thread_id).toMatch(/^thread_/)
expect(result.messages[0].thread_id).toBe(result.messages[1].thread_id)
})
it('throws when group resolves to no recipients', async () => {
setupWithTerminals([makeSummary('term_a')])
await expect(
call('orchestration.send', {
from: 'term_a',
to: '@all',
subject: 'nobody home'
})
).rejects.toThrow('No recipients resolved for group address')
})
})
describe('orchestration.check', () => {
it('returns unread messages for a terminal', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'one' })
db.insertMessage({ from: 'a', to: 'b', subject: 'two' })
db.insertMessage({ from: 'a', to: 'c', subject: 'other' })
const result = (await call('orchestration.check', {
terminal: 'b'
})) as { messages: unknown[]; count: number }
expect(result.count).toBe(2)
})
it('returns formatted output with --inject', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'test' })
const result = (await call('orchestration.check', {
terminal: 'b',
inject: true
})) as { formatted: string; count: number }
expect(result.formatted).toContain('Subject: test')
expect(result.count).toBe(1)
})
it('filters by type', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'status', type: 'status' })
db.insertMessage({ from: 'a', to: 'b', subject: 'done', type: 'worker_done' })
const result = (await call('orchestration.check', {
terminal: 'b',
types: 'worker_done'
})) as { count: number }
expect(result.count).toBe(1)
})
it('rejects invalid type filters', async () => {
setup()
await expect(
call('orchestration.check', {
terminal: 'b',
types: 'worker_done,typo'
})
).rejects.toThrow('Invalid --types')
})
})
describe('orchestration.reply', () => {
it('replies to a message', async () => {
setup()
const original = db.insertMessage({ from: 'a', to: 'b', subject: 'question' })
const result = (await call('orchestration.reply', {
id: original.id,
body: 'answer',
from: 'b'
})) as { message: { to_handle: string; subject: string; thread_id: string } }
expect(result.message.to_handle).toBe('a')
expect(result.message.subject).toBe('Re: question')
expect(result.message.thread_id).toBe(original.id)
})
it('throws on nonexistent message', async () => {
setup()
await expect(call('orchestration.reply', { id: 'msg_fake', body: 'nope' })).rejects.toThrow(
'Message not found'
)
})
})
describe('orchestration.inbox', () => {
it('returns all messages', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'one' })
db.insertMessage({ from: 'c', to: 'd', subject: 'two' })
const result = (await call('orchestration.inbox', {})) as { count: number }
expect(result.count).toBe(2)
})
})
describe('orchestration.taskCreate', () => {
it('creates a task', async () => {
setup()
const result = (await call('orchestration.taskCreate', {
spec: 'implement feature X'
})) as { task: { id: string; status: string } }
expect(result.task.id).toMatch(/^task_/)
expect(result.task.status).toBe('ready')
})
it('creates a task with deps', async () => {
setup()
const t1 = db.createTask({ spec: 'first' })
const result = (await call('orchestration.taskCreate', {
spec: 'second',
deps: JSON.stringify([t1.id])
})) as { task: { status: string } }
expect(result.task.status).toBe('pending')
})
it('rejects invalid deps JSON', async () => {
setup()
await expect(
call('orchestration.taskCreate', { spec: 'bad', deps: 'not-json' })
).rejects.toThrow('Invalid --deps')
})
})
describe('orchestration.taskList', () => {
it('lists all tasks', async () => {
setup()
db.createTask({ spec: 'a' })
db.createTask({ spec: 'b' })
const result = (await call('orchestration.taskList', {})) as { count: number }
expect(result.count).toBe(2)
})
it('filters by status', async () => {
setup()
db.createTask({ spec: 'a' })
const t2 = db.createTask({ spec: 'b' })
db.updateTaskStatus(t2.id, 'completed')
const result = (await call('orchestration.taskList', {
status: 'ready'
})) as { count: number }
expect(result.count).toBe(1)
})
it('rejects invalid status filters', () => {
const method = findMethod('orchestration.taskList')
expect(() => method.params!.parse({ status: 'done-ish' })).toThrow()
})
})
describe('orchestration.taskUpdate', () => {
it('updates task status', async () => {
setup()
const task = db.createTask({ spec: 'work' })
const result = (await call('orchestration.taskUpdate', {
id: task.id,
status: 'completed',
result: '{"ok": true}'
})) as { task: { status: string; result: string } }
expect(result.task.status).toBe('completed')
expect(result.task.result).toBe('{"ok": true}')
})
it('completion frees the active dispatch context', async () => {
setup()
const task = db.createTask({ spec: 'work' })
db.createDispatchContext(task.id, 'term_a')
await call('orchestration.taskUpdate', {
id: task.id,
status: 'completed'
})
expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined()
})
it('throws on nonexistent task', async () => {
setup()
await expect(
call('orchestration.taskUpdate', { id: 'task_fake', status: 'completed' })
).rejects.toThrow('Task not found')
})
})
describe('orchestration.dispatch', () => {
it('dispatches a task to a terminal', async () => {
setup()
const task = db.createTask({ spec: 'work' })
const result = (await call('orchestration.dispatch', {
task: task.id,
to: 'term_a'
})) as { dispatch: { task_id: string; status: string } }
expect(result.dispatch.task_id).toBe(task.id)
expect(result.dispatch.status).toBe('dispatched')
})
it('rejects dispatch for a pending task', async () => {
setup()
const parent = db.createTask({ spec: 'parent' })
const child = db.createTask({ spec: 'child', deps: [parent.id] })
await expect(
call('orchestration.dispatch', {
task: child.id,
to: 'term_a'
})
).rejects.toThrow('only ready tasks can be dispatched')
})
it('rolls back active dispatch when injection fails', async () => {
setup()
const task = db.createTask({ spec: 'work' })
vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true)
vi.spyOn(runtime, 'sendTerminal').mockRejectedValue(new Error('terminal_not_writable'))
await expect(
call('orchestration.dispatch', {
task: task.id,
to: 'term_a',
inject: true
})
).rejects.toThrow('terminal_not_writable')
expect(db.getTask(task.id)?.status).toBe('ready')
expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined()
})
it('uses caller-provided dev mode for injected preamble', async () => {
setup()
const task = db.createTask({ spec: 'work' })
vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true)
const send = vi.spyOn(runtime, 'sendTerminal').mockResolvedValue({
handle: 'term_a',
accepted: true,
bytesWritten: 1
})
await call('orchestration.dispatch', {
task: task.id,
to: 'term_a',
inject: true,
devMode: true
})
expect(send.mock.calls[0]?.[1].text).toContain('orca-dev orchestration send')
})
it('rejects inject to terminal without recognized agent', async () => {
setup()
const task = db.createTask({ spec: 'work' })
vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(false)
await expect(
call('orchestration.dispatch', {
task: task.id,
to: 'term_a',
inject: true
})
).rejects.toThrow('no recognized agent detected')
})
it('rejects dispatch to occupied terminal', async () => {
setup()
const t1 = db.createTask({ spec: 'first' })
const t2 = db.createTask({ spec: 'second' })
db.createDispatchContext(t1.id, 'term_a')
await expect(call('orchestration.dispatch', { task: t2.id, to: 'term_a' })).rejects.toThrow(
/already has an active dispatch/
)
})
})
describe('orchestration.dispatchShow', () => {
it('shows dispatch context for a task', async () => {
setup()
const task = db.createTask({ spec: 'work' })
db.createDispatchContext(task.id, 'term_a')
const result = (await call('orchestration.dispatchShow', {
task: task.id
})) as { dispatch: { task_id: string } | null }
expect(result.dispatch?.task_id).toBe(task.id)
})
it('returns null for unknown task', async () => {
setup()
const result = (await call('orchestration.dispatchShow', {
task: 'task_fake'
})) as { dispatch: null }
expect(result.dispatch).toBeNull()
})
})
describe('orchestration.gateCreate', () => {
it('creates a decision gate and blocks the task', async () => {
setup()
const task = db.createTask({ spec: 'needs approval' })
const result = (await call('orchestration.gateCreate', {
task: task.id,
question: 'Proceed with migration?',
options: JSON.stringify(['yes', 'no', 'defer'])
})) as { gate: { id: string; task_id: string; status: string } }
expect(result.gate.id).toMatch(/^gate_/)
expect(result.gate.task_id).toBe(task.id)
expect(result.gate.status).toBe('pending')
const updated = db.getTask(task.id)
expect(updated?.status).toBe('blocked')
})
it('rejects invalid options JSON', async () => {
setup()
const task = db.createTask({ spec: 'work' })
await expect(
call('orchestration.gateCreate', {
task: task.id,
question: 'ok?',
options: 'not-json'
})
).rejects.toThrow('Invalid --options')
})
it('rejects options that are not string arrays', async () => {
setup()
const task = db.createTask({ spec: 'work' })
await expect(
call('orchestration.gateCreate', {
task: task.id,
question: 'ok?',
options: JSON.stringify(['yes', 1])
})
).rejects.toThrow('Invalid --options')
})
})
describe('orchestration.gateResolve', () => {
it('resolves a gate and unblocks the task', async () => {
setup()
const task = db.createTask({ spec: 'needs approval' })
const gate = db.createGate({ taskId: task.id, question: 'Proceed?' })
const result = (await call('orchestration.gateResolve', {
id: gate.id,
resolution: 'yes'
})) as { gate: { id: string; status: string; resolution: string } }
expect(result.gate.status).toBe('resolved')
expect(result.gate.resolution).toBe('yes')
const updated = db.getTask(task.id)
expect(updated?.status).toBe('ready')
})
it('throws on nonexistent gate', async () => {
setup()
await expect(
call('orchestration.gateResolve', { id: 'gate_fake', resolution: 'yes' })
).rejects.toThrow('Gate not found')
})
})
describe('orchestration.gateList', () => {
it('lists all gates', async () => {
setup()
const t1 = db.createTask({ spec: 'a' })
const t2 = db.createTask({ spec: 'b' })
db.createGate({ taskId: t1.id, question: 'q1' })
db.createGate({ taskId: t2.id, question: 'q2' })
const result = (await call('orchestration.gateList', {})) as { count: number }
expect(result.count).toBe(2)
})
it('filters by status', async () => {
setup()
const task = db.createTask({ spec: 'work' })
const gate = db.createGate({ taskId: task.id, question: 'q' })
db.resolveGate(gate.id, 'yes')
const result = (await call('orchestration.gateList', {
status: 'resolved'
})) as { count: number }
expect(result.count).toBe(1)
})
it('rejects invalid status filters', () => {
const method = findMethod('orchestration.gateList')
expect(() => method.params!.parse({ status: 'closed' })).toThrow()
})
})
describe('orchestration.reset', () => {
it('resets all state', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'test' })
db.createTask({ spec: 'work' })
const result = (await call('orchestration.reset', { all: true })) as { reset: string }
expect(result.reset).toBe('all')
expect(db.getInbox()).toHaveLength(0)
expect(db.listTasks()).toHaveLength(0)
})
it('resets tasks only', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'test' })
db.createTask({ spec: 'work' })
await call('orchestration.reset', { tasks: true })
expect(db.getInbox()).toHaveLength(1)
expect(db.listTasks()).toHaveLength(0)
})
it('resets messages only', async () => {
setup()
db.insertMessage({ from: 'a', to: 'b', subject: 'test' })
db.createTask({ spec: 'work' })
await call('orchestration.reset', { messages: true })
expect(db.getInbox()).toHaveLength(0)
expect(db.listTasks()).toHaveLength(1)
})
})
})

View File

@ -0,0 +1,402 @@
/* eslint-disable max-lines -- Why: RPC method definitions co-locate param schemas with handlers; splitting by method would scatter the shared enums and Zod transforms without reducing complexity. */
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalFiniteNumber, OptionalString, OptionalBoolean, requiredString } from '../schemas'
import type { MessageType, MessagePriority, TaskStatus } from '../../orchestration/db'
import { buildDispatchPreamble } from '../../orchestration/preamble'
import { formatMessageBanner } from '../../orchestration/formatter'
import { isGroupAddress, resolveGroupAddress } from '../../orchestration/groups'
import { ORCHESTRATION_GATE_METHODS } from './orchestration-gates'
const MESSAGE_TYPES: MessageType[] = [
'status',
'dispatch',
'worker_done',
'merge_ready',
'escalation',
'handoff',
'decision_gate'
]
const TASK_STATUSES: TaskStatus[] = [
'pending',
'ready',
'dispatched',
'completed',
'failed',
'blocked'
]
const SendParams = z.object({
to: requiredString('Missing --to'),
subject: requiredString('Missing --subject'),
from: OptionalString,
body: OptionalString,
type: z
.enum([
'status',
'dispatch',
'worker_done',
'merge_ready',
'escalation',
'handoff',
'decision_gate'
])
.optional(),
priority: z.enum(['normal', 'high', 'urgent']).optional(),
threadId: OptionalString,
payload: OptionalString,
devMode: OptionalBoolean
})
const CheckParams = z.object({
terminal: OptionalString,
unread: OptionalBoolean,
types: OptionalString,
inject: OptionalBoolean,
wait: OptionalBoolean,
timeoutMs: OptionalFiniteNumber
})
const ReplyParams = z.object({
id: requiredString('Missing --id'),
body: requiredString('Missing --body'),
from: OptionalString
})
const InboxParams = z.object({
limit: OptionalFiniteNumber
})
const TaskCreateParams = z.object({
spec: requiredString('Missing --spec'),
deps: OptionalString,
parent: OptionalString
})
const TaskListParams = z.object({
status: z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked']).optional(),
ready: OptionalBoolean
})
const TaskUpdateParams = z.object({
id: requiredString('Missing --id'),
status: z
.unknown()
.transform((v) => {
if (typeof v === 'string' && TASK_STATUSES.includes(v as TaskStatus)) {
return v as TaskStatus
}
return ''
})
.pipe(
z.enum(['pending', 'ready', 'dispatched', 'completed', 'failed', 'blocked'], {
message: 'Missing --status'
})
),
result: OptionalString
})
const DispatchParams = z.object({
task: requiredString('Missing --task'),
to: requiredString('Missing --to'),
from: OptionalString,
inject: OptionalBoolean,
devMode: OptionalBoolean
})
const DispatchShowParams = z.object({
task: OptionalString
})
const ResetParams = z.object({
all: OptionalBoolean,
tasks: OptionalBoolean,
messages: OptionalBoolean
})
export const ORCHESTRATION_METHODS: RpcMethod[] = [
defineMethod({
name: 'orchestration.send',
params: SendParams,
handler: async (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const from = params.from ?? 'unknown'
if (!isGroupAddress(params.to)) {
// Point-to-point — existing single-recipient behavior
const msg = db.insertMessage({
from,
to: params.to,
subject: params.subject,
body: params.body,
type: params.type as MessageType,
priority: params.priority as MessagePriority,
threadId: params.threadId,
payload: params.payload
})
runtime.deliverPendingMessagesForHandle(params.to)
runtime.notifyMessageArrived(params.to)
return { message: msg }
}
// Why: group addresses fan out to one message per recipient so each gets
// independent read-tracking, but they share a thread_id so the conversation
// can be correlated (Section 4.5).
const { terminals } = await runtime.listTerminals()
const handles = resolveGroupAddress(params.to, from, terminals, (handle: string) =>
runtime.getAgentStatusForHandle(handle)
)
if (handles.length === 0) {
throw new Error(`No recipients resolved for group address: ${params.to}`)
}
const threadId = params.threadId ?? `thread_${Date.now()}`
const messages = handles.map((handle) =>
db.insertMessage({
from,
to: handle,
subject: params.subject,
body: params.body,
type: params.type as MessageType,
priority: params.priority as MessagePriority,
threadId,
payload: params.payload
})
)
for (const handle of handles) {
runtime.deliverPendingMessagesForHandle(handle)
runtime.notifyMessageArrived(handle)
}
return { messages, recipients: handles.length }
}
}),
defineMethod({
name: 'orchestration.check',
params: CheckParams,
handler: async (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const handle = params.terminal ?? 'unknown'
const typeFilter = params.types
? (params.types
.split(',')
.map((t) => t.trim())
.filter(Boolean) as MessageType[])
: undefined
const invalidTypes = typeFilter?.filter((t) => !MESSAGE_TYPES.includes(t))
if (invalidTypes && invalidTypes.length > 0) {
throw new Error(`Invalid --types: ${invalidTypes.join(',')}`)
}
const showUnread = params.unread !== false
const readAndReturn = () => {
const messages = showUnread
? db.getUnreadMessages(handle, typeFilter)
: db.getAllMessages(handle)
if (showUnread && messages.length > 0) {
db.markAsRead(messages.map((m) => m.id))
}
if (params.inject) {
const formatted = messages.map(formatMessageBanner).join('\n\n')
return { messages, formatted, count: messages.length }
}
return { messages, count: messages.length }
}
const result = readAndReturn()
if (result.count > 0 || !params.wait) {
return result
}
// Why: blocking wait lets coordinators replace sleep+poll loops with a
// single call that resolves when a message arrives or the timeout expires.
await runtime.waitForMessage(handle, {
typeFilter: typeFilter as string[] | undefined,
timeoutMs: params.timeoutMs ?? undefined
})
return readAndReturn()
}
}),
defineMethod({
name: 'orchestration.reply',
params: ReplyParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const original = db.getMessageById(params.id)
if (!original) {
throw new Error(`Message not found: ${params.id}`)
}
db.markAsRead([original.id])
const reply = db.insertMessage({
from: params.from ?? original.to_handle,
to: original.from_handle,
subject: `Re: ${original.subject}`,
body: params.body,
threadId: original.thread_id ?? original.id
})
runtime.notifyMessageArrived(original.from_handle)
return { message: reply }
}
}),
defineMethod({
name: 'orchestration.inbox',
params: InboxParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const messages = db.getInbox(params.limit)
return { messages, count: messages.length }
}
}),
defineMethod({
name: 'orchestration.taskCreate',
params: TaskCreateParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
let deps: string[] | undefined
if (params.deps) {
try {
const parsed = JSON.parse(params.deps)
if (!Array.isArray(parsed) || !parsed.every((d) => typeof d === 'string')) {
throw new Error('not an array of strings')
}
deps = parsed
} catch {
throw new Error('Invalid --deps: must be a JSON array of task IDs')
}
}
const task = db.createTask({
spec: params.spec,
deps,
parentId: params.parent
})
return { task }
}
}),
defineMethod({
name: 'orchestration.taskList',
params: TaskListParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const tasks = db.listTasks({
status: params.status as TaskStatus,
ready: params.ready
})
return { tasks, count: tasks.length }
}
}),
defineMethod({
name: 'orchestration.taskUpdate',
params: TaskUpdateParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const task = db.updateTaskStatus(params.id, params.status, params.result)
if (!task) {
throw new Error(`Task not found: ${params.id}`)
}
return { task }
}
}),
defineMethod({
name: 'orchestration.dispatch',
params: DispatchParams,
handler: async (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const task = db.getTask(params.task)
if (!task) {
throw new Error(`Task not found: ${params.task}`)
}
if (task.status !== 'ready') {
throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`)
}
// Why: dispatching with --inject to a bare shell (zsh/bash) dumps the
// preamble as shell commands, producing gibberish. Check both OSC title
// status and foreground process — Claude Code doesn't emit recognized OSC
// titles on startup, so title-only detection misses freshly spawned agents.
if (params.inject) {
const hasAgent = await runtime.isTerminalRunningAgent(params.to)
if (!hasAgent) {
throw new Error(
`Cannot dispatch --inject to terminal ${params.to}: no recognized agent detected. ` +
'Start an agent CLI (e.g. claude, codex, gemini) in the terminal first, ' +
'or dispatch without --inject and send the prompt manually.'
)
}
}
const ctx = db.createDispatchContext(params.task, params.to)
let injected = false
if (params.inject) {
try {
const preamble = buildDispatchPreamble({
taskId: task.id,
taskSpec: task.spec,
coordinatorHandle: params.from ?? 'coordinator',
devMode: params.devMode
})
await runtime.sendTerminal(params.to, { text: preamble, enter: true })
injected = true
} catch (err) {
db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err))
throw err
}
}
return { dispatch: ctx, injected }
}
}),
defineMethod({
name: 'orchestration.dispatchShow',
params: DispatchShowParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
if (!params.task) {
throw new Error('Missing --task')
}
const ctx = db.getDispatchContext(params.task)
return { dispatch: ctx ?? null }
}
}),
...ORCHESTRATION_GATE_METHODS,
defineMethod({
name: 'orchestration.reset',
params: ResetParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
if (params.all) {
db.resetAll()
return { reset: 'all' }
}
if (params.tasks) {
db.resetTasks()
return { reset: 'tasks' }
}
if (params.messages) {
db.resetMessages()
return { reset: 'messages' }
}
db.resetAll()
return { reset: 'all' }
}
})
]

View File

@ -247,7 +247,8 @@ describe('OrcaRuntimeRpcServer', () => {
writes.push(data)
return true
},
kill: () => true
kill: () => true,
getForegroundProcess: async () => null
})
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath })
@ -341,7 +342,7 @@ describe('OrcaRuntimeRpcServer', () => {
id: 'req_send',
ok: true
})
expect(writes).toEqual(['continue\r'])
expect(writes).toEqual(['continue', '\r'])
const waitPromise = sendRequest(metadata!.transport!.endpoint, {
id: 'req_wait',

View File

@ -34,6 +34,7 @@ import type { SshPortForwardManager } from './ssh-port-forward'
import type { SshConnection } from './ssh-connection'
import type { DetectedPort } from '../../shared/ssh-types'
import type { Store } from '../persistence'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
@ -54,6 +55,7 @@ export class SshRelaySession {
private getMainWindow: () => BrowserWindow | null,
private store: Store,
private portForwardManager: SshPortForwardManager,
private runtime?: OrcaRuntimeService,
private onDetectedPortsChanged?: (
targetId: string,
ports: DetectedPort[],
@ -462,6 +464,7 @@ export class SshRelaySession {
private wireUpPtyEvents(ptyProvider: SshPtyProvider): void {
const getWin = this.getMainWindow
ptyProvider.onData((payload) => {
this.runtime?.onPtyData(payload.id, payload.data, Date.now())
const win = getWin()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:data', payload)
@ -476,6 +479,7 @@ export class SshRelaySession {
ptyProvider.onExit((payload) => {
clearProviderPtyState(payload.id)
deletePtyOwnership(payload.id)
this.runtime?.onPtyExit(payload.id, payload.code)
const win = getWin()
if (win && !win.isDestroyed()) {
win.webContents.send('pty:exit', payload)

View File

@ -63,6 +63,7 @@ describe('run-electron-vite-dev', () => {
env: {
...process.env,
ORCA_ELECTRON_VITE_CLI: fakeCliPath,
ORCA_SKIP_DEV_CLI_PREPARE: '1',
ORCA_DEV_WRAPPER_TEST_PID_FILE: pidFile
},
stdio: 'ignore'

View File

@ -54,7 +54,7 @@ export function attachMainWindowServices(
}
return ids
})
registerSshHandlers(store, () => mainWindow)
registerSshHandlers(store, () => mainWindow, runtime)
registerFileDropRelay(mainWindow)
setupAutoUpdater(mainWindow, {
getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt,

View File

@ -1,8 +1,10 @@
import { useEffect, useState } from 'react'
import { RotateCw } from 'lucide-react'
import { Copy, RotateCw } from 'lucide-react'
import { toast } from 'sonner'
import type { GlobalSettings } from '../../../../shared/types'
import { Button } from '../ui/button'
import { Label } from '../ui/label'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
import { useAppStore } from '../../store'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch } from './settings-search'
@ -10,6 +12,9 @@ import { EXPERIMENTAL_PANE_SEARCH_ENTRIES } from './experimental-search'
export { EXPERIMENTAL_PANE_SEARCH_ENTRIES }
const ORCHESTRATION_SKILL_INSTALL_COMMAND =
'npx skills add https://github.com/stablyai/orca --skill orchestration'
type ExperimentalPaneProps = {
settings: GlobalSettings
updateSettings: (updates: Partial<GlobalSettings>) => void
@ -49,6 +54,37 @@ export function ExperimentalPane({
}, [])
const showDaemon = matchesSettingsSearch(searchQuery, [EXPERIMENTAL_PANE_SEARCH_ENTRIES[0]])
const showOrchestration = matchesSettingsSearch(searchQuery, [
EXPERIMENTAL_PANE_SEARCH_ENTRIES[1]
])
const [orchestrationEnabled, setOrchestrationEnabled] = useState<boolean>(() => {
return localStorage.getItem('orca.orchestration.enabled') === '1'
})
const [orchestrationSkillInstalled, setOrchestrationSkillInstalled] = useState<boolean>(() => {
return localStorage.getItem('orca.orchestration.skillInstalled') === '1'
})
const toggleOrchestration = (value: boolean): void => {
setOrchestrationEnabled(value)
localStorage.setItem('orca.orchestration.enabled', value ? '1' : '0')
}
const markOrchestrationSkillInstalled = (value: boolean): void => {
setOrchestrationSkillInstalled(value)
localStorage.setItem('orca.orchestration.skillInstalled', value ? '1' : '0')
}
const handleCopyOrchestrationCommand = async (): Promise<void> => {
try {
await window.api.ui.writeClipboardText(ORCHESTRATION_SKILL_INSTALL_COMMAND)
toast.success('Copied install command. Run it in your agent project.')
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to copy command.')
}
}
const pendingRestart =
daemonEnabledAtStartup !== null &&
settings.experimentalTerminalDaemon !== daemonEnabledAtStartup
@ -139,6 +175,87 @@ export function ExperimentalPane({
</SearchableSetting>
) : null}
{showOrchestration ? (
<SearchableSetting
title="Agent Orchestration"
description="Coordinate multiple coding agents via messaging, task DAGs, dispatch, and decision gates."
keywords={EXPERIMENTAL_PANE_SEARCH_ENTRIES[1].keywords}
className="space-y-3 px-1 py-2"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 shrink space-y-0.5">
<Label>Agent Orchestration</Label>
<p className="text-xs text-muted-foreground">
Coordinate multiple coding agents with messaging, task DAGs, dispatch with preamble
injection, decision gates, and coordinator loops. Experimental APIs may change.
</p>
</div>
<button
role="switch"
aria-checked={orchestrationEnabled}
onClick={() => toggleOrchestration(!orchestrationEnabled)}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors ${
orchestrationEnabled ? 'bg-foreground' : 'bg-muted-foreground/30'
}`}
>
<span
className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow-sm transition-transform ${
orchestrationEnabled ? 'translate-x-4' : 'translate-x-0.5'
}`}
/>
</button>
</div>
{orchestrationEnabled ? (
<div className="space-y-3 rounded-xl border border-border/60 bg-card/50 p-4">
<div className="space-y-1">
<p className="text-sm font-medium">Install Orchestration Skill</p>
<p className="text-xs text-muted-foreground">
Run this in your agent project so agents learn to use inter-agent orchestration
commands.
</p>
</div>
<div className="flex max-w-full items-center gap-2 rounded-lg border border-border/60 bg-background/60 px-3 py-2">
<code className="flex-1 overflow-x-auto whitespace-nowrap text-[11px] text-muted-foreground">
{ORCHESTRATION_SKILL_INSTALL_COMMAND}
</code>
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => void handleCopyOrchestrationCommand()}
aria-label="Copy orchestration skill install command"
>
<Copy className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Copy
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span>
{orchestrationSkillInstalled
? 'Marked as installed on this machine.'
: "Check off once you've run it in your project."}
</span>
<button
type="button"
className="underline-offset-2 hover:text-foreground hover:underline"
onClick={() => markOrchestrationSkillInstalled(!orchestrationSkillInstalled)}
>
{orchestrationSkillInstalled ? 'Undo' : 'I ran it'}
</button>
</div>
</div>
) : null}
</SearchableSetting>
) : null}
{hiddenExperimentalUnlocked ? <HiddenExperimentalGroup /> : null}
</div>
)

View File

@ -16,5 +16,23 @@ export const EXPERIMENTAL_PANE_SEARCH_ENTRIES: SettingsSearchEntry[] = [
'scrollback',
'reattach'
]
},
{
title: 'Agent Orchestration',
description:
'Coordinate multiple coding agents via messaging, task DAGs, dispatch, and decision gates.',
keywords: [
'experimental',
'orchestration',
'multi-agent',
'agents',
'coordination',
'messaging',
'dispatch',
'task',
'DAG',
'worker',
'coordinator'
]
}
]

View File

@ -83,17 +83,4 @@ export function buildAgentStartupPlan(args: {
}
}
export function isShellProcess(processName: string): boolean {
const normalized = processName.trim().toLowerCase()
return (
normalized === '' ||
normalized === 'bash' ||
normalized === 'zsh' ||
normalized === 'sh' ||
normalized === 'fish' ||
normalized === 'cmd.exe' ||
normalized === 'powershell.exe' ||
normalized === 'pwsh.exe' ||
normalized === 'nu'
)
}
export { isShellProcess } from '../../../shared/agent-detection'

View File

@ -98,12 +98,14 @@ async function syncRuntimeGraph(): Promise<void> {
ptyId: savedPtyId
})
}
const paneTitles = state.runtimePaneTitlesByTabId[tabId] ?? {}
graph.leaves.push({
tabId,
worktreeId: registeredTab.worktreeId,
leafId,
paneRuntimeId: pane.id,
ptyId
ptyId,
paneTitle: paneTitles[pane.id] ?? null
})
}
}

View File

@ -434,3 +434,23 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null {
return null
}
// Why: shared between the runtime (dispatch guard, tui-idle fallback) and the
// renderer (agent-ready-wait, new-workspace). A bare shell is the only process
// type that garbles injected preambles, so this is the negative signal for
// "is an agent running".
const SHELL_NAMES = new Set([
'',
'bash',
'zsh',
'sh',
'fish',
'cmd.exe',
'powershell.exe',
'pwsh.exe',
'nu'
])
export function isShellProcess(processName: string): boolean {
return SHELL_NAMES.has(processName.trim().toLowerCase())
}

View File

@ -49,6 +49,7 @@ export type RuntimeSyncedLeaf = {
leafId: string
paneRuntimeId: number
ptyId: string | null
paneTitle?: string | null
}
export type RuntimeSyncWindowGraph = {