From c9391e203f701937438615eba9d4a0d64fdb7dc7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Tue, 28 Apr 2026 12:21:31 -0700 Subject: [PATCH] feat(orchestration): add inter-agent orchestration system (#1188) Co-authored-by: Orca --- .github/workflows/pr.yml | 6 + bin/orca-dev | 28 + config/scripts/install-dev-cli.mjs | 50 ++ config/scripts/run-electron-vite-dev.mjs | 48 ++ package.json | 9 +- pnpm-lock.yaml | 165 +++++ scripts/rebuild-native-deps.mjs | 14 + skills/orca-cli/SKILL.md | 10 +- src/cli/args.ts | 3 +- src/cli/dispatch.ts | 4 +- src/cli/handlers/orchestration.ts | 263 +++++++ src/cli/help.ts | 17 + src/cli/index.test.ts | 73 ++ src/cli/specs/index.ts | 4 +- src/cli/specs/orchestration.ts | 119 ++++ src/main/ipc/pty.test.ts | 57 +- src/main/ipc/pty.ts | 55 +- src/main/ipc/ssh.test.ts | 39 ++ src/main/ipc/ssh.ts | 5 +- src/main/runtime/orca-runtime.test.ts | 169 ++++- src/main/runtime/orca-runtime.ts | 501 ++++++++++++- .../runtime/orchestration/coordinator.test.ts | 379 ++++++++++ src/main/runtime/orchestration/coordinator.ts | 491 +++++++++++++ src/main/runtime/orchestration/db.test.ts | 457 ++++++++++++ src/main/runtime/orchestration/db.ts | 561 +++++++++++++++ .../runtime/orchestration/formatter.test.ts | 104 +++ src/main/runtime/orchestration/formatter.ts | 43 ++ src/main/runtime/orchestration/groups.test.ts | 129 ++++ src/main/runtime/orchestration/groups.ts | 71 ++ .../runtime/orchestration/preamble.test.ts | 82 +++ src/main/runtime/orchestration/preamble.ts | 41 ++ src/main/runtime/orchestration/types.ts | 77 ++ src/main/runtime/rpc/methods/index.ts | 4 +- .../rpc/methods/orchestration-gates.ts | 154 ++++ .../runtime/rpc/methods/orchestration.test.ts | 656 ++++++++++++++++++ src/main/runtime/rpc/methods/orchestration.ts | 402 +++++++++++ src/main/runtime/runtime-rpc.test.ts | 5 +- src/main/ssh/ssh-relay-session.ts | 4 + .../startup/run-electron-vite-dev.test.ts | 1 + .../window/attach-main-window-services.ts | 2 +- .../components/settings/ExperimentalPane.tsx | 119 +++- .../settings/experimental-search.ts | 18 + src/renderer/src/lib/tui-agent-startup.ts | 15 +- .../src/runtime/sync-runtime-graph.ts | 4 +- src/shared/agent-detection.ts | 20 + src/shared/runtime-types.ts | 1 + 46 files changed, 5407 insertions(+), 72 deletions(-) create mode 100755 bin/orca-dev create mode 100644 config/scripts/install-dev-cli.mjs create mode 100644 src/cli/handlers/orchestration.ts create mode 100644 src/cli/specs/orchestration.ts create mode 100644 src/main/runtime/orchestration/coordinator.test.ts create mode 100644 src/main/runtime/orchestration/coordinator.ts create mode 100644 src/main/runtime/orchestration/db.test.ts create mode 100644 src/main/runtime/orchestration/db.ts create mode 100644 src/main/runtime/orchestration/formatter.test.ts create mode 100644 src/main/runtime/orchestration/formatter.ts create mode 100644 src/main/runtime/orchestration/groups.test.ts create mode 100644 src/main/runtime/orchestration/groups.ts create mode 100644 src/main/runtime/orchestration/preamble.test.ts create mode 100644 src/main/runtime/orchestration/preamble.ts create mode 100644 src/main/runtime/orchestration/types.ts create mode 100644 src/main/runtime/rpc/methods/orchestration-gates.ts create mode 100644 src/main/runtime/rpc/methods/orchestration.test.ts create mode 100644 src/main/runtime/rpc/methods/orchestration.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 05e9310f0..678f79746 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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 diff --git a/bin/orca-dev b/bin/orca-dev new file mode 100755 index 000000000..97acbf9f1 --- /dev/null +++ b/bin/orca-dev @@ -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" "$@" diff --git a/config/scripts/install-dev-cli.mjs b/config/scripts/install-dev-cli.mjs new file mode 100644 index 000000000..1c74e797d --- /dev/null +++ b/config/scripts/install-dev-cli.mjs @@ -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}` + ) +} diff --git a/config/scripts/run-electron-vite-dev.mjs b/config/scripts/run-electron-vite-dev.mjs index 8cc21ebe1..1bfcb87c6 100644 --- a/config/scripts/run-electron-vite-dev.mjs +++ b/config/scripts/run-electron-vite-dev.mjs @@ -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. diff --git a/package.json b/package.json index 92f55da5d..70960a914 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04678674d..95bfd66b0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/scripts/rebuild-native-deps.mjs b/scripts/rebuild-native-deps.mjs index 6fdb6b7b3..ecfe6dfcd 100644 --- a/scripts/rebuild-native-deps.mjs +++ b/scripts/rebuild-native-deps.mjs @@ -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) diff --git a/skills/orca-cli/SKILL.md b/skills/orca-cli/SKILL.md index 2e3305f95..c9d51287e 100644 --- a/skills/orca-cli/SKILL.md +++ b/skills/orca-cli/SKILL.md @@ -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 --for exit` only when the task actually depends on process completion. - Use `terminal wait --terminal --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 diff --git a/src/cli/args.ts b/src/cli/args.ts index e65c0b290..f61cfb751 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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' && diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index fdde0baef..5b245c992 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -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 @@ -34,7 +35,8 @@ function buildHandlers(): Map { 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)) { diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts new file mode 100644 index 000000000..be807c28a --- /dev/null +++ b/src/cli/handlers/orchestration.ts @@ -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, + cwd: string, + client: Parameters[0]['client'], + flagName: 'from' | 'terminal' +): Promise { + 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 = { + '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}`) + } +} diff --git a/src/cli/help.ts b/src/cli/help.ts index 2028b9ffd..864d5e526 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 579556bac..8783968bf 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -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, diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index dfa343aed..a29a48b6a 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -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 ] diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts new file mode 100644 index 000000000..e527fe8b9 --- /dev/null +++ b/src/cli/specs/orchestration.ts @@ -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 --subject [--from ] [--body ] [--type ] [--priority ] [--thread-id ] [--payload ] [--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 ] [--unread] [--types ] [--inject] [--wait] [--timeout-ms ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'unread', 'types', 'inject', 'wait', 'timeout-ms'] + }, + { + path: ['orchestration', 'reply'], + summary: 'Reply to a message', + usage: 'orca orchestration reply --id --body [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'body', 'from'] + }, + { + path: ['orchestration', 'inbox'], + summary: 'Show all messages across recipients', + usage: 'orca orchestration inbox [--limit ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'limit'] + }, + { + path: ['orchestration', 'task-create'], + summary: 'Create an orchestration task', + usage: + 'orca orchestration task-create --spec [--deps ] [--parent ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'spec', 'deps', 'parent'] + }, + { + path: ['orchestration', 'task-list'], + summary: 'List orchestration tasks', + usage: 'orca orchestration task-list [--status ] [--ready] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready'] + }, + { + path: ['orchestration', 'task-update'], + summary: 'Update a task status', + usage: + 'orca orchestration task-update --id --status [--result ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result'] + }, + { + path: ['orchestration', 'dispatch'], + summary: 'Dispatch a task to a terminal', + usage: + 'orca orchestration dispatch --task --to [--from ] [--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 [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task'] + }, + { + path: ['orchestration', 'run'], + summary: 'Start the coordinator loop', + usage: + 'orca orchestration run --spec [--from ] [--poll-interval-ms ] [--max-concurrent ] [--worktree ] [--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 --question [--options ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'question', 'options'] + }, + { + path: ['orchestration', 'gate-resolve'], + summary: 'Resolve a pending decision gate', + usage: 'orca orchestration gate-resolve --id --resolution [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'id', 'resolution'] + }, + { + path: ['orchestration', 'gate-list'], + summary: 'List decision gates', + usage: 'orca orchestration gate-list [--task ] [--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'] + } +] diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 6c78e3753..91fdfe1b7 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -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) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index ee334f25c..0945d9d72 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -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 | 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) } diff --git a/src/main/ipc/ssh.test.ts b/src/main/ipc/ssh.test.ts index afa456305..1308ceec3 100644 --- a/src/main/ipc/ssh.test.ts +++ b/src/main/ipc/ssh.test.ts @@ -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) diff --git a/src/main/ipc/ssh.ts b/src/main/ipc/ssh.ts index 4aac9e517..91bba2f04 100644 --- a/src/main/ipc/ssh.ts +++ b/src/main/ipc/ssh.ts @@ -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) } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index e4e44b932..310a20c76 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index a9fa0024c..b221afcff 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 } 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() private handles = new Map() private handleByLeafKey = new Map() + private handleByPtyId = new Map() + private detachedPreAllocatedLeaves = new Map() private graphSyncCallbacks: (() => void)[] = [] private waitersByHandle = new Map>() 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>() 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() + // 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 { + 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 { + 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, diff --git a/src/main/runtime/orchestration/coordinator.test.ts b/src/main/runtime/orchestration/coordinator.test.ts new file mode 100644 index 000000000..ac0ebbb49 --- /dev/null +++ b/src/main/runtime/orchestration/coordinator.test.ts @@ -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') + }) +}) diff --git a/src/main/runtime/orchestration/coordinator.ts b/src/main/runtime/orchestration/coordinator.ts new file mode 100644 index 000000000..541102f4a --- /dev/null +++ b/src/main/runtime/orchestration/coordinator.ts @@ -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 + 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> & { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + const result = await this.runtime.listTerminals(this.opts.worktree) + const dispatched = this.db.listTasks({ status: 'dispatched' }) + const busyHandles = new Set() + + 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms) + }) + } +} diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts new file mode 100644 index 000000000..d412255fd --- /dev/null +++ b/src/main/runtime/orchestration/db.test.ts @@ -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) + }) + }) +}) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts new file mode 100644 index 000000000..b7c74bee7 --- /dev/null +++ b/src/main/runtime/orchestration/db.ts @@ -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() + } +} diff --git a/src/main/runtime/orchestration/formatter.test.ts b/src/main/runtime/orchestration/formatter.test.ts new file mode 100644 index 000000000..2b47d4027 --- /dev/null +++ b/src/main/runtime/orchestration/formatter.test.ts @@ -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 { + 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}`) + }) +}) diff --git a/src/main/runtime/orchestration/formatter.ts b/src/main/runtime/orchestration/formatter.ts new file mode 100644 index 000000000..f5526553d --- /dev/null +++ b/src/main/runtime/orchestration/formatter.ts @@ -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` +} diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts new file mode 100644 index 000000000..61794da9e --- /dev/null +++ b/src/main/runtime/orchestration/groups.test.ts @@ -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 { + 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:', () => { + 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([]) + }) + }) +}) diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts new file mode 100644 index 000000000..8c9475925 --- /dev/null +++ b/src/main/runtime/orchestration/groups.ts @@ -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: — 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 [] +} diff --git a/src/main/runtime/orchestration/preamble.test.ts b/src/main/runtime/orchestration/preamble.test.ts new file mode 100644 index 000000000..71c27d50f --- /dev/null +++ b/src/main/runtime/orchestration/preamble.test.ts @@ -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') + }) +}) diff --git a/src/main/runtime/orchestration/preamble.ts b/src/main/runtime/orchestration/preamble.ts new file mode 100644 index 000000000..3c61f0be5 --- /dev/null +++ b/src/main/runtime/orchestration/preamble.ts @@ -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: " \\ + --body "
" + + # 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}` +} diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts new file mode 100644 index 000000000..1d0ae9a39 --- /dev/null +++ b/src/main/runtime/orchestration/types.ts @@ -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 +} diff --git a/src/main/runtime/rpc/methods/index.ts b/src/main/runtime/rpc/methods/index.ts index bff8a215b..9bdcc514c 100644 --- a/src/main/runtime/rpc/methods/index.ts +++ b/src/main/runtime/rpc/methods/index.ts @@ -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 ] diff --git a/src/main/runtime/rpc/methods/orchestration-gates.ts b/src/main/runtime/rpc/methods/orchestration-gates.ts new file mode 100644 index 000000000..ca16b9719 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration-gates.ts @@ -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 } + } + }) +] diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts new file mode 100644 index 000000000..64a2507d0 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -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) { + 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 { + 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 + ): 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: 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) + }) + }) +}) diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts new file mode 100644 index 000000000..5c467e369 --- /dev/null +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -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' } + } + }) +] diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 594a0161b..58fff491f 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -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', diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index c5cec0658..2a972db6a 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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) diff --git a/src/main/startup/run-electron-vite-dev.test.ts b/src/main/startup/run-electron-vite-dev.test.ts index c1fea6eab..e56e8c79c 100644 --- a/src/main/startup/run-electron-vite-dev.test.ts +++ b/src/main/startup/run-electron-vite-dev.test.ts @@ -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' diff --git a/src/main/window/attach-main-window-services.ts b/src/main/window/attach-main-window-services.ts index b1e71587e..2fcf6271f 100644 --- a/src/main/window/attach-main-window-services.ts +++ b/src/main/window/attach-main-window-services.ts @@ -54,7 +54,7 @@ export function attachMainWindowServices( } return ids }) - registerSshHandlers(store, () => mainWindow) + registerSshHandlers(store, () => mainWindow, runtime) registerFileDropRelay(mainWindow) setupAutoUpdater(mainWindow, { getLastUpdateCheckAt: () => store.getUI().lastUpdateCheckAt, diff --git a/src/renderer/src/components/settings/ExperimentalPane.tsx b/src/renderer/src/components/settings/ExperimentalPane.tsx index 66cac215b..61c4c894a 100644 --- a/src/renderer/src/components/settings/ExperimentalPane.tsx +++ b/src/renderer/src/components/settings/ExperimentalPane.tsx @@ -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) => 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(() => { + return localStorage.getItem('orca.orchestration.enabled') === '1' + }) + + const [orchestrationSkillInstalled, setOrchestrationSkillInstalled] = useState(() => { + 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 => { + 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({ ) : null} + {showOrchestration ? ( + +
+
+ +

+ Coordinate multiple coding agents with messaging, task DAGs, dispatch with preamble + injection, decision gates, and coordinator loops. Experimental — APIs may change. +

+
+ +
+ + {orchestrationEnabled ? ( +
+
+

Install Orchestration Skill

+

+ Run this in your agent project so agents learn to use inter-agent orchestration + commands. +

+
+
+ + {ORCHESTRATION_SKILL_INSTALL_COMMAND} + + + + + + + + Copy + + + +
+
+ + {orchestrationSkillInstalled + ? 'Marked as installed on this machine.' + : "Check off once you've run it in your project."} + + +
+
+ ) : null} +
+ ) : null} + {hiddenExperimentalUnlocked ? : null} ) diff --git a/src/renderer/src/components/settings/experimental-search.ts b/src/renderer/src/components/settings/experimental-search.ts index 821ed6ad4..5f44bae17 100644 --- a/src/renderer/src/components/settings/experimental-search.ts +++ b/src/renderer/src/components/settings/experimental-search.ts @@ -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' + ] } ] diff --git a/src/renderer/src/lib/tui-agent-startup.ts b/src/renderer/src/lib/tui-agent-startup.ts index 1ba2e70ba..59c64c0f0 100644 --- a/src/renderer/src/lib/tui-agent-startup.ts +++ b/src/renderer/src/lib/tui-agent-startup.ts @@ -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' diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index 3ebe8fb08..f7502bdeb 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -98,12 +98,14 @@ async function syncRuntimeGraph(): Promise { 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 }) } } diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index a3f4416d5..caba0ead3 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -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()) +} diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 1d0835c80..adba1447e 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -49,6 +49,7 @@ export type RuntimeSyncedLeaf = { leafId: string paneRuntimeId: number ptyId: string | null + paneTitle?: string | null } export type RuntimeSyncWindowGraph = {