mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
feat(orchestration): make multi-agent workflows durable (#16904)
<!-- orca-pr-loc -->
<!-- Programmatic LoC summary. Do not edit by hand; rewritten on every commit. -->
| | Files | Added | Deleted | Net |
| :--- | ---: | ---: | ---: | ---: |
| Test | 225 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$21666 | $\color{#cf222e}{\Huge{\mathbf{−}}}$2820 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$18846 |
| Prod | 348 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$17107 | $\color{#cf222e}{\Huge{\mathbf{−}}}$4706 | $\color{#1a7f37}{\Huge{\mathbf{+}}}$12401 |
<!-- /orca-pr-loc -->
## ELI5
Orca now treats orchestration like a durable control plane instead of inferring success from terminal keystrokes. Agents can tell whether a prompt was accepted or a turn started, replay an ambiguous request without sending twice, and recover coordinator mail after a crash. Completed workers can be inspected, released, or retained, and their panes no longer auto-resume as if the work were still running.
## What changed
- **Run receipts** from `run-create/use/current/show/list` are the row without routing plumbing (`home_database`, `coordinator_pane_key`) and without the duplicate `binding` object.
- **`terminal send` receipts are honest and idempotent.** `input_accepted` and `turn_started` are the only stages; `--wait-submit` observes without resending; `--retry-request <uuid>` replays the exact request against the same process incarnation. A transport timeout keeps the retry ID; only a different runtime answering strips it. Value-less or non-UUID `--retry-request` is rejected on the CLI and the SSH shim.
- **Mailbox delivery is committed before wakeup.** Pointer writes are staged in the DB before any PTY byte, replayed once after restart, and never emit a naked Enter. The watermark that parks concurrent deliveries is released with the DB reservation. Restart rescans pointer-pending and `dispatch:` mailboxes.
- **Lifecycle is a guarded transition graph** (`lifecycle-transition.ts`) with a table-driven test over every caller edge. Task reopen/overturn stays in the public contract. A PTY exit during `worker-stop` is the stop succeeding, not a failure.
- **Worker lifecycle CLI:** `worker-start` (`--spec` creates Task + attempt in one call), `worker-show`, `worker-read` (provider transcript first, bounded terminal fallback with a typed reason, local/WSL/SSH), `worker-stop`, `worker-abandon`, `worker-release`, `worker-retain`, `worker-list` (rowid-fenced pagination, fleet liveness, `attention`, literal `nextAction`).
- **Release is an explicit ownership table** (`decideWorkerTerminalRelease`): only an `owned` resource can be settled, the archive is mandatory where reachable, and an owner whose process is proven exited can always get out of `retained` via `archive_status: unavailable`. User-taken-over, external, and transferred panes stay retained.
- **Settled-worker resume fence** (folds in #17651): a settled dispatch whose pane is still open is fenced at settlement, on stop/abandon/exit, and at startup; lifted on release, retain, takeover, and pane reuse.
- **Liveness is `live` / `unverifiable` / `exited` only**, from execution-host evidence. Fleet projection reads the evidence clock, not the relay delivery clock. A host-certified exit outranks the worker's settled state. `unverifiable` never authorizes stop, abandon, retry, or release, in code or in the guide.
- **Federation:** structured reads negotiate by `method_not_found` so every shipped host keeps transcript-first output; exited remote workers are closed before being reported closed; epoch fencing holds across peer restart, downgrade, and pairing rotation; no per-second forced capability probe.
- **Schema v35:** repairs databases stamped v34 by the pre-fix branch (mailbox_handle default, index predicates), drops the write-only `lifecycle_transition_receipts` ledger and five never-read v31 identity columns.
- **Schema v36:** `dispatch:<id>` mailboxes get a real consumer generation on `dispatch_contexts` and `remote_dispatch_attachments`, bumped and fenced in the same transaction on every re-attach (manual inject, worker-start, federated attach). A stale worker whose Dispatch moved to another process now gets `consumer_fenced` instead of silently acking the new worker's Delivery. Run mailboxes already worked this way.
- **Schema v37:** `dispatch_contexts` records its creator (`creator_handle`, `creator_pane_key`), so a coordinator's context-only self-dispatch is bookkeeping rather than a nesting parent; before this, one self-dispatch made every later `worker-start` from that coordinator fail the depth cap. Pre-v37 rows keep counting (fails closed).
- **Dispatch-mailbox ownership is checked, not inferred.** A `check` from a process whose pane no longer holds the Dispatch, or whose last Attempt was abandoned/failed and moved to another terminal, gets `consumer_fenced` instead of an empty inbox that reads as "no mail yet". `--peek`/`--all` stay readable. A paneless caller still gets `stable_pane_required` with the rebind recovery.
- **Liveness certification is stricter:** a `process_exited` stage whose termination reason is `unknown` (a stop that was issued but never observed) projects `unverifiable`, not `exited`. Federated `worker-show` carries the execution host's verdict and host kind instead of a local guess. A live, ready worker with nothing pending has `nextAction: none` rather than pointing at the `worker-show` that produced it.
- **Wire:** `workerShow` keeps `dispatch.task_id` next to `taskId` for shipped CLIs. `ask --json` uses the standard `{ok, result}` envelope like every sibling verb.
- **Migration start-version detection** treats the two v32 recovery columns as versioned. Before this, every shipped database stamped below 32 resolved to the v6 floor and replayed the whole chain (the v23 backfill synthesized 68 phantom retained workers on a real v30 profile). Verified on a copy of a real 62 MB v30 profile: starts at 30, no row delta, integrity ok, 11 ms.
- **Skill guide** rewritten as a ≤200-line kernel plus seven references, to the outcome-first standard (Result / Done / Safe failure first, conditions not case lists, one done bar, references loaded at the point of use). The canonical loop uses `worker-start --spec`, names `worker-list` for completion accounting, documents `--retry-request` / `request-show` / `--wait-submit`, and requires positive evidence before any stall action. The other seven guides get the same treatment in #18724, split out so this PR stays orchestration-only.
- **`rpc/methods/orchestration-*`** (126 flat files) regrouped into `orchestration/{worker,federation,messaging,runs,gates}/`.
## Why
User reports showed the same boundary failures: false `agent_prompt_stalled` causing duplicate sends (#15180), coordinators unable to trust screen scrapes, cold-parked terminals receiving a pointer without the submit, settled workers accumulating as live tabs and auto-resuming after restart, and no way to tell a stalled worker from a working one.
## Linked issues
Fixes #15180. Fixes #17935 (orchestration skill description is 866 characters; a guard now caps every bundled skill at 1,024). Supersedes #17651 (fence folded in). Advances #16660, #16522, #14907, #13047.
## Review record
This PR was reviewed adversarially after revival: eight independent lenses (lifecycle, mailbox, send, worker, federation, transcript, complexity, live ergonomics), each required to prove findings with a failing test. That produced 16 proven blockers, all fixed with red-then-green regression tests, followed by two re-review rounds and a third fix wave that caught 3 regressions introduced by the fixes and 7 fixes that missed their target; all closed. A final pass (five lenses incl. a live built-runtime smoke, then a re-review of the fix wave) found and fixed seven more, chiefly the stale-worker mailbox steal, the self-dispatch depth wedge, and the unproven-exit certification. Three independent Codex (gpt-6-astra) passes followed: the first found nothing new, the second found and fixed 3 defects (task-status reachability, WSL-local host classification, peer-capability epoch), the third found and fixed 6 (production PTY controller never installed settled writes, ambiguous in-flight pointer failures allowed duplicate replay, SSH/relay deadlines cut off a valid `--wait-submit`, stop-vs-exit race during inspection, and two release-recovery paths for vanished or exited terminals). The full record (findings, proof tests, triage, declines with reasons) is archived outside the repo.
**Rework after the live smoke.** A first live cross-host run on the shipped adhoc build (this Mac, a paired Windows host on the same build, a paired Mac on 1.4.195, and an SSH host) found a P1: a running local worker read `unverifiable`/`missing_status` because the fleet snapshot rows lacked the terminal handle the matcher keyed on. A 59-row failure table over every bug fixed during review showed the same two classes recurring: a fact dropped in transit through optional fields, and two authorities for one fact. Two blind designs (Opus, Codex) converged on the same mechanisms, and the scoped tranches landed here with red-then-green seam tests from the real producer to the real consumer, faults injected only at the transport or hook-ingest boundary:
- **Settlement (data-loss class):** one three-valued `WriteSettlement` (`accepted | refused{reason} | unverifiable{reason, bytesHandedToTransport}`) from the SSH multiplexer through daemon client, providers, controller, to pointer staging. No boolean, no rejection-as-third-state. The two silent degrades that fabricated a handoff are deleted; a provider that cannot settle refuses before any effect. Pointer text and Enter share the contract; a partial flush is `unverifiable`, never `refused`.
- **Evidence identity (false-liveness class):** fleet agent-status evidence is a tagged union (`binding: worker | pane | unresolved{reason}`, `clock: observed | delivery`) minted once at ingest, so a hook row captured on one process incarnation can never bind to a later dispatch on the same pane. The matcher's `!worker.paneKey ||` defaults are gone. One host-scope parser replaces two.
- **Small pre-merge items:** `capability_unsupported` from an old peer is no longer relabelled `host_unavailable`; a producer census test asserts every agent-status consumer path projects a pane-only hook row as `live`.
Two ergonomics defects the second live run surfaced on a real database are fixed here too: a pre-v3 dispatch already marked `completed` projected as `outcome_unknown` / `requiresAction: true` forever (three copies of the outcome ladder disagreed on legacy rows; now one resolver, legacy `completed` reads `succeeded` with nothing to act on, legacy `failed` stays actionable on the failure), and an unscoped `worker-list` enumerated the entire database (now defaults to the Run bound to the calling terminal, `--run` overrides, and the receipt's additive `scope` field says which).
A third live round on the shipped adhoc build of `b082443e1f` (same four hosts) plus an unscripted run in the user's own prompt style (a plain Claude Code shell, `/orchestration`, three workers, zero errors, bound-Run default confirmed) found two more branch defects, fixed with red-then-green tests: a worker freshly started on a paired server projected `unverifiable`/`host_indeterminate` with `requiresAction` for ~3 minutes, including after its own `worker_done`, because the host's federation observation returned `missing_liveness_verdict` for any PTY the liveness register had not yet swept (the host now reads a connected pane it owns locally as `live`; disconnected or SSH-scoped panes stay `unverifiable`); and six pre-v3 completed rows still carried an `input` category because settling through the task-status path or `failDispatch` never closed the Dispatch's pending question threads (both paths close them now, and schema v38 closes threads already pending on settled rows). The guide's `worker-start` examples now show `--model sonnet`, since an omitted model inherits the launcher's default.
A Codex adversarial pass on the tranche diff found one real design hole (identity minted at read time instead of ingest, now closed) and two daemon settlement paths that threw instead of settling (fixed). Two `@ts-nocheck` runtime mixins on these paths were extracted into checked modules; the repo-wide `@ts-nocheck` count is unchanged at 171.
Deletions during review: ~1,900 lines (write-only ledger, unread columns, dead v1 archive path, test harnesses shipped in prod, duplicated liveness and state-machine copies, self-capability checks that were compile-time true).
## Testing
- `pnpm typecheck:tsc:node|cli|web` clean
- `pnpm run check:code-quality:changed` 0 findings; `check:react-doctor:changed` 0
- `pnpm verify:bundled-skill-guides`, `verify:skill-bundle-manifest`
- full `pnpm test` on the integrated head: 72,332 pass / 292 skipped; the only failures were three non-PR files (two zsh live-shell suites hit a node-pty spawn-helper ENOENT while a concurrent native rebuild ran, 44/44 in isolation; `release-checkout.unit.test.ts` is a known 30 s load timeout that passes in isolation on `origin/main` too).
- CI on 70b4811267 (rerun, pre-Codex): the only reds are five SSH e2e specs plus `terminal-send-agent-prompt-submit:198`, each shown failing identically on main (main's E2E workflow is red on its last 40 runs). The terminal-send spec is root-caused and fixed separately in #18707. The Windows hook-service flake (#17721) and the federation load flake did not recur.
- Skills: `pnpm exec vitest run` over the skill gate files plus `src/cli`, `config/scripts`, `src/main/skills` pass; live smoke on the built CLI of `skills get orchestration` and `--full` (7 references).
- live headless runtime (`orca-dev serve`, isolated profile): canonical loop, stop, release, archive read, retry rejection, stale-handle check, SIGKILL-and-replay all verified with receipts
- Live cross-host smoke on the shipped adhoc build of `0d465e7931` (this Mac and a paired Windows host on the build, a paired Mac left on 1.4.195, an SSH host): local, paired-new, paired-old and SSH loops all settle; running workers read `live` on every host and `exited` after release; the old peer reads `capability_unsupported` and refuses release honestly. Injected 10 s relay stall with a send in flight: delivered exactly once after recovery, zero duplicates. Every liveness field across 104 receipts is only `live` / `unverifiable` / `exited`.
- Final live cross-host smoke on the shipped adhoc build of `b082443e1f` (same hosts): every loop settles; 942 of 948 legacy completed rows read settled with `requiresAction: false` before the question-thread fix and all of them after; `worker-list` scope reads `bound` / `flag` / `all` correctly; 122 JSON receipts carry only `live` / `unverifiable` / `exited`. Unscripted prompt-style run: clean.
- Confirmation smoke on the shipped adhoc build of `2da076d4e9` (this Mac and the paired Windows host, both updated): a freshly started Windows worker reads `live` on the first fleet poll and on all 20 that follow, with no `host_indeterminate` at any point, and `exited` after release; all 948 legacy completed rows read `requiresAction: false` with `nextAction: none` after schema v38; every verdict across 60 receipts is `live` / `unverifiable` / `exited`.
- Not physically exercised: WSL hosts, the renderer notification bell (headless has no renderer), same-session fence via a real pane close (renderer-only state), restart mid-delivery on a real app (covered by e2e only).
## Notes
- Remote-wire additions are optional fields or `method_not_found`-negotiated methods; one new Electron-only IPC channel (`agentStatus:legacyWorkerTerminalResumeFence`) never crosses the wire.
- SSH contact loss remains `unverifiable`; the execution host stays authoritative.
- Intentional wire projection change: an SSH host scope with an empty `targetId` now projects host id `ssh` instead of an empty string (remote-wire-compatibility rule 3, old clients decode the same field). A fleet pane key without a terminal handle is now `unidentifiable` rather than matched by pane key alone.
- Found live but pre-existing on main, filed separately: a relay daemon-start collision during transport loss rewrites the endpoint credential and wedges the surviving relay (host needs a manual kill); `terminal create` on a reconnecting SSH host reports an opaque `No PTY provider for connection`; `terminal list` reports `orphaned:false` and `terminal close` reports `ptyKilled:true` for a pane whose relay is gone (orchestration's own projection reads `unverifiable` correctly at the same moment).
- Downgrade after this PR is not a supported path: main opens a v37 database and early-returns (its inserts still work against the v36/v37 defaulted columns), but its one-outstanding-Delivery-per-Run index is a no-op against the branch's mailbox-scoped index of the same name.
- Known follow-ups (not blockers): `worker-list` materializes every dispatch row per call; a positive "agent absent" signal distinct from PTY liveness is a product decision left open (a headless fake agent never reaches `live`, so its `nextAction` stays `inspect`); a context-only self-dispatch still lists as `role: worker` in `worker-list`; `dispatch` task-not-found / task-not-ready / inject-rejected still surface as `runtime_error`; task and inbox receipts still expose raw row columns. Deferred skill product decisions live on #18724.
This commit is contained in:
+112
-67
@@ -12959,15 +12959,15 @@
|
||||
"invariant": "Injected orchestration task prompts for recognized agent CLIs must send the prompt body inside one bracketed-paste frame, sanitize embedded ESC bytes, preserve chunk boundaries without losing the frame, and submit exactly once only after the agent can accept Enter. A successful orchestration.workerStart must durably record exactly one accepted and started turn; a swallowed Enter must fail with agent_prompt_stalled and never trigger a blind rescue Enter. Claude and Codex must emit a post-paste composer marker and then settle, or reach the bounded fallback first; every other agent retains the platform delay.",
|
||||
"oracle": "Runtime tests assert the exact PTY write sequence, failure cleanup, Claude/Codex marker-gated multi-frame renders, and the legacy platform delay for every other configured agent. The candidate resets settlement on later frames, gives a late marker a fresh bounded window, and still submits once at the hard deadline if output never settles. The worker-start contract drives the production RPC through a delayed fake Codex composer and independently checks exact turn/Enter counts plus reopened SQLite Task, Dispatch, worker receipt, and mutation receipt state for accepted and swallowed outcomes. Other orchestration tests assert dispatch/coordinator use the agent prompt path; the live CLI harness covers long Codex-like framing.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts --reporter=dot",
|
||||
"node tests/tools/repro-orchestration-long-prompt.mjs --cli out/bin/orca-dev --mode codex-like --size-kb 32 --timeout-ms 20000"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/shared/agent-prompt-injection.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts",
|
||||
"src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"tests/tools/repro-orchestration-long-prompt.mjs"
|
||||
],
|
||||
@@ -12995,7 +12995,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts",
|
||||
"assertions": [
|
||||
"orchestration.dispatch uses the agent prompt path for injected preambles",
|
||||
"raw terminal.send is not called for injected task prompts",
|
||||
@@ -13003,7 +13003,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts",
|
||||
"assertions": [
|
||||
"delayed composer readiness produces exactly one submitted and started turn with no premature Enter and durable ready receipts",
|
||||
"a swallowed Enter records agent_prompt_stalled across Task, Dispatch, worker, and mutation receipts without a rescue Enter"
|
||||
@@ -13030,7 +13030,7 @@
|
||||
"date": "2026-08-23",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-start-prompt-contract.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/worker/worker-start-prompt-contract.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 21.84,
|
||||
"summary": "Two deterministic worker-start RPC contracts passed with fake clocks and reopened SQLite receipts for one accepted turn and one swallowed-Enter stalled outcome."
|
||||
@@ -13039,7 +13039,7 @@
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 11.32,
|
||||
"summary": "4 files and 1,303 tests passed with one skipped. Claude and Codex both wait for post-marker quiescence, and a Codex marker arriving at 7.9 seconds receives a fresh window through its final slow frame. Exact-build live Codex workers accepted injected prompts without manual Enter, replied, called worker_done, and settled successfully in the rendered Electron UI."
|
||||
@@ -13048,7 +13048,7 @@
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 13.3,
|
||||
"summary": "4 files and 1,283 tests passed. The hardened multi-frame oracle failed on the first-marker candidate because it submitted at 751 ms during an intermediate Claude frame; the quiescence candidate waited through the final 1,000 ms frame and submitted once at 2,500 ms. Continuous render output remained bounded to one fallback submit at 8 seconds. An isolated Claude Code 2.1.231 Haiku probe saw the first marker at 400 ms, continued output through 1,500 ms, sent one Enter at 3,000 ms after 1.5 seconds quiet, and created the expected marker; no Fable or Opus probe was used."
|
||||
@@ -13057,7 +13057,7 @@
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 16.9,
|
||||
"summary": "4 files and 1,282 tests passed. Unmodified main wrote Enter at 500 ms before the deterministic Claude composer rendered at 750 ms; the candidate waited for the split show-cursor marker and wrote one Enter. A live Claude Code 2.1.231 Haiku trace rendered the pasted marker and show-cursor in one 523-byte frame without submitting a model request."
|
||||
@@ -13066,7 +13066,7 @@
|
||||
"date": "2026-07-07",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/shared/agent-prompt-injection.test.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/rpc/methods/orchestration/runs/tasks-dispatch.test.ts src/main/runtime/orchestration/coordinator.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 7.4,
|
||||
"summary": "4 test files passed, 697 tests passed; covers framing, runtime PTY writes, orchestration RPC dispatch, and coordinator dispatch behavior."
|
||||
@@ -13136,12 +13136,12 @@
|
||||
"internal incident evidence: improve-vps-setup, 2026-08-10"
|
||||
],
|
||||
"invariant": "Each message has one stable row ID and authoritative recipient; coordinator-addressed current-delivery inserts are atomically owned by run:<id>. Pointer staging may set delivered_at but never consumes mail. Each Run consumer generation has at most one outstanding Delivery with a fixed ID and fixed message IDs; ordinary checks replay it until an explicit matching acknowledgment marks exactly those rows read. Rebinding fences the old generation, notification types/counts correspond to unread rows retrievable under the same authority, and federation replay imports each stable message identity once without re-waking an already-read duplicate.",
|
||||
"oracle": "Seed status, dispatch, and worker_done rows across direct-handle and canonical Run recipients in an isolated DB. Compare pointer count, RPC and built-CLI check output, direct SQLite rows, unread/peek/all/type filters, concurrent pollers, fixed Delivery IDs, explicit acknowledgment, restart, filtered check --wait, and coordinator remint. Route a 125-row old-handle backlog, inject a commit without notification, and require startup repair. Exercise duplicate Run/Dispatch owners, stale panes, 50-row pages, cancellation, lifecycle fencing, and absent PTYs. Drop a federation ACK, reconnect/restart v1/v2 peers, and require stable import plus no duplicate read-row wake. Hold a healthy SSH write past five seconds but below the 60-second settlement deadline, then separately exceed the bound and require retryable undelivered state.",
|
||||
"oracle": "Seed status, dispatch, and worker_done rows across direct-handle and canonical Run recipients in an isolated DB. Compare pointer count, RPC and built-CLI check output, direct SQLite rows, unread/peek/all/type filters, concurrent pollers, fixed Delivery IDs, explicit acknowledgment, restart, filtered check --wait, and coordinator remint. Route a 125-row old-handle backlog, inject a commit without notification, and require startup repair. Exercise duplicate Run/Dispatch owners, stale panes, 50-row pages, cancellation, lifecycle fencing, and absent PTYs. Drop a federation ACK, reconnect/restart v1/v2 peers, and require stable import plus no duplicate read-row wake. Hold a healthy SSH write past five seconds but below the 60-second settlement deadline, then distinguish the three settlement outcomes end to end: only a proven refusal releases the reservation and drains a delivery parked behind the watermark; a dropped in-flight settlement must surface as unverifiable with bytes handed to the transport, preserve the durable write-attempted reservation, and emit no duplicate pointer after restart; a settled write that throws mid-pointer is unverifiable, not a refusal; and an Enter whose settlement is lost stays at enter-attempted so restart emits no second Enter. Install the production PTY controller and verify that it routes settled writes through the owning provider and refuses before any byte when the routed provider cannot settle. Census every production PTY provider class and reject a settlement synthesized from the fire-and-forget write.",
|
||||
"commands": [
|
||||
"pnpm run build:cli && pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-message-delivery-identity.test.ts --reporter=dot --testTimeout=5000",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration-runs.test.ts src/main/runtime/rpc/methods/orchestration-send.test.ts src/main/runtime/rpc/methods/orchestration-check.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot"
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/orchestration-message-delivery-identity.test.ts",
|
||||
@@ -13149,23 +13149,26 @@
|
||||
"src/main/runtime/orchestration-mailbox-detached-routing.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-routing-races.test.ts",
|
||||
"src/main/runtime/orchestration-mailbox-transport-settlement.test.ts",
|
||||
"src/main/ipc/pty-controller-ownership-routing.test.ts",
|
||||
"src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts",
|
||||
"src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts",
|
||||
"src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts",
|
||||
"src/main/runtime/orchestration/formatter.test.ts",
|
||||
"src/main/providers/ssh-pty-provider.test.ts",
|
||||
"src/main/providers/ssh-pty-write.test.ts",
|
||||
"src/main/providers/settled-pty-writer-census.test.ts",
|
||||
"src/main/runtime/orchestration/mailbox-pointer-stage.test.ts",
|
||||
"src/main/daemon/client.test.ts",
|
||||
"src/main/daemon/daemon-pty-router.test.ts",
|
||||
"src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"src/main/runtime/orca-runtime.test.ts",
|
||||
"src/main/runtime/terminal-send-stale-leaf-liveness.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-runs.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-send.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-check.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts",
|
||||
"src/main/runtime/orchestration/federation-sync.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts"
|
||||
"src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
@@ -13229,14 +13232,14 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts",
|
||||
"assertions": [
|
||||
"a lost relay acknowledgment retries without duplicating the home message",
|
||||
"a reordered relay gap converges without loss or duplication"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts",
|
||||
"assertions": [
|
||||
"protocol v1 and v2 completion acknowledgments replay after Run-home restart",
|
||||
"terminal settlement remains replayable until the worker durably acknowledges it"
|
||||
@@ -13245,13 +13248,36 @@
|
||||
{
|
||||
"file": "src/main/runtime/orchestration-mailbox-transport-settlement.test.ts",
|
||||
"assertions": [
|
||||
"a rejected pointer transport stays undelivered and becomes restart-retryable"
|
||||
"a refused pointer transport releases its reservation, stays undelivered, and becomes restart-retryable",
|
||||
"a dropped in-flight SSH settlement reaches the stager as unverifiable with bytes handed to the transport and emits no duplicate pointer after restart",
|
||||
"a settled write that throws mid-pointer preserves the write-attempted reservation",
|
||||
"an Enter whose settlement is lost stays at enter-attempted and restart emits no second Enter"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/orchestration/mailbox-pointer-stage.test.ts",
|
||||
"assertions": [
|
||||
"a refused pointer write drains a delivery parked behind its watermark"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/providers/settled-pty-writer-census.test.ts",
|
||||
"assertions": [
|
||||
"every production IPtyProvider class exposes a settled writer",
|
||||
"no settled writer synthesizes its settlement from the fire-and-forget write"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/ipc/pty-controller-ownership-routing.test.ts",
|
||||
"assertions": [
|
||||
"the installed controller preserves provider uncertainty instead of flattening it",
|
||||
"a routed provider that cannot settle is refused before any byte reaches its write"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/daemon/client.test.ts",
|
||||
"assertions": [
|
||||
"an asynchronous daemon socket write failure settles as rejected",
|
||||
"an asynchronous daemon socket write failure settles as unverifiable, never as a proven refusal",
|
||||
"a wedged daemon socket write disconnects at its bounded settlement deadline"
|
||||
]
|
||||
},
|
||||
@@ -13276,11 +13302,20 @@
|
||||
}
|
||||
],
|
||||
"evidenceRuns": [
|
||||
{
|
||||
"date": "2026-09-05",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.73,
|
||||
"summary": "267 tests passed after the pointer-write path moved to the three-valued WriteSettlement union. New coverage: a dropped in-flight SSH settlement reaches the stager as unverifiable with bytes handed to the transport, a settled write that throws mid-pointer preserves the write-attempted reservation, an Enter whose settlement is lost stays at enter-attempted with no second Enter after restart, a refusal releases the reservation and drains a delivery parked behind its watermark, the production controller refuses before any byte when the routed provider cannot settle, and a census pins the five production IPtyProvider classes and rejects a settlement synthesized from the fire-and-forget write. Each new assertion was verified red against the pre-fix shape."
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration-mailbox-routing-races.test.ts src/main/runtime/orchestration-mailbox-notification-consistency.test.ts src/main/runtime/orchestration-mailbox-detached-routing.test.ts src/main/runtime/orchestration-mailbox-transport-settlement.test.ts src/main/ipc/pty-controller-ownership-routing.test.ts src/main/runtime/orchestration/run-coordinator-handle-migration.test.ts src/main/runtime/orchestration/orchestration-run-delivery-db.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/orchestration/formatter.test.ts src/main/providers/ssh-pty-provider.test.ts src/main/providers/ssh-pty-write.test.ts src/main/providers/settled-pty-writer-census.test.ts src/main/runtime/orchestration/mailbox-pointer-stage.test.ts src/main/daemon/client.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.22,
|
||||
"summary": "245 tests passed across mailbox identity, durable coordinator-handle migration, insertion-time canonicalization, duplicate-free 51-row ownership branch caps, unrestricted reservation merging, direct and Dispatch pointer suppression, persisted reconciliation, 50-row paging and filtered waits, cross-PTY serialization, lifecycle fencing, bounded daemon and SSH transport settlement, outstanding Deliveries, reminted Dispatch ownership, acknowledgment, cancellation, and bounded pane lookup."
|
||||
@@ -13289,7 +13324,7 @@
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-sync.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.99,
|
||||
"summary": "52 tests passed with real OrchestrationDb rows, a deliberately dropped federation acknowledgment, reconnect/restart, forward-only checkpoints, duplicate read-row wake suppression, and protocol v1/v2 lifecycle settlement replay. The broader final federation/cross-version set passed 77/77."
|
||||
@@ -13307,7 +13342,7 @@
|
||||
"date": "2026-08-14",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration-runs.test.ts src/main/runtime/rpc/methods/orchestration-send.test.ts src/main/runtime/rpc/methods/orchestration-check.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-send-stale-leaf-liveness.test.ts src/main/runtime/rpc/methods/orchestration/runs/runs.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 14.15,
|
||||
"summary": "1,293 tests passed and 1 was skipped across Run-bound pointer delivery, PTY retirement and respawn, stale-leaf liveness, direct-mail routing, filtered waiter ownership, canonical stored-recipient notification, and orchestration RPC behavior."
|
||||
@@ -13375,10 +13410,10 @@
|
||||
"oracle": "Drive Run create, Task create, and worker-start through production Electron runtimes with a deterministic Codex fixture. Require append-only ledgers with one still-live PID and no interruption, a visible inactive worker tab while the coordinator stays active, Run delivery through stable pane identity, and stable PTY/incarnation, tab, leaf, worktree, Task, and Dispatch across workspace re-entry. In a restart journey, retain the original daemon PTY and PID, remove renderer ownership, retain sleeping-session evidence, mark the Dispatch legacy, relaunch, and require exact inactive tab adoption, readable ACK output, cleared resume state, one spawn, and no resume argv or Conversation interrupted text after another workspace round trip. The service oracle removes renderer lookup identity from current-contract callers while retaining real restored-PTY and hook commitments, replays authenticated completion and takeover across fresh runtimes, and requires one Task, Dispatch, terminal authority, message, mutation, ordinary-mail delivery, remote process fencing, and unchanged fixture marker bytes while foreign pane evidence remains rejected. Unit tests separately remint a creator pane and process from Run A into Run B, require the nested Run A worker to fall back to its current coordinator, require indexed query plans, and bound 300 Task reads with 50,000 retained Runs. They also assert authority-specific legacy affordances, exact identity and owner matching, retained-output fallback, pane-stable routing, federated non-activation, and SSH fallback parity.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/orchestration-runtime-update-settlement.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-check.test.ts src/main/runtime/rpc/methods/orchestration-send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration-lifecycle-rejection.test.ts src/cli/handlers/orchestration-lifecycle-json-rejection.test.ts src/cli/handlers/orchestration-migration.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-legacy-worker-terminal-recovery.test.ts",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/orchestration-creator-authority-performance.test.ts",
|
||||
@@ -13399,11 +13434,11 @@
|
||||
"src/cli/handlers/orchestration-migration.test.ts",
|
||||
"src/cli/handlers/orchestration-check-identity.test.ts",
|
||||
"src/cli/handlers/orchestration-worker-cli.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-check.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-send.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts",
|
||||
"src/main/runtime/orchestration/federation-acknowledgment-migration.test.ts",
|
||||
"src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"tests/e2e/orchestration-worker-terminal-visibility.spec.ts",
|
||||
@@ -13486,27 +13521,27 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts",
|
||||
"assertions": [
|
||||
"same-workspace worker creation uses visible inactive presentation",
|
||||
"worker-start preserves and reports renderer reveal failures"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-check.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts",
|
||||
"assertions": [
|
||||
"Run delivery resolves through a stable coordinator pane after handle remint",
|
||||
"a live handle cannot be retargeted by mismatched pane metadata"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-send.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts",
|
||||
"assertions": [
|
||||
"Dispatch delivery resolves through a stable worker pane after handle remint"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts",
|
||||
"assertions": [
|
||||
"a remote worker_done waits for Run-home settlement even when an older CLI omits the wait hint",
|
||||
"protocol v1/v2 clients can start fresh workers and complete success or failure on a current worker server",
|
||||
@@ -13533,7 +13568,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts",
|
||||
"assertions": ["federated worker placement explicitly sets activate=false"]
|
||||
},
|
||||
{
|
||||
@@ -13578,7 +13613,7 @@
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 6.02,
|
||||
"summary": "The 70f1d52f mixed-version oracle passed all 21 cases. Protocol v1/v2 clients started fresh workers on a current server, completed success and failure with explicit legacy authority, and automatically retried a lost ACK after Run-home restart; current-protocol settlement and duplicate-report controls stayed green."
|
||||
@@ -13587,7 +13622,7 @@
|
||||
"date": "2026-08-13",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "failed",
|
||||
"durationSeconds": 4.21,
|
||||
"summary": "The byte-identical 70f1d52f oracle failed 6 mixed-version cases while 15 controls passed when the fresh v1/v2 refusal was restored: success and failure through both negotiated versions plus both lost-ACK restart cases."
|
||||
@@ -13596,7 +13631,7 @@
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "failed",
|
||||
"durationSeconds": 5.05,
|
||||
"summary": "The byte-identical ac7bdf4e federation oracle failed 7 of 17 tests on affected 09ec516ae5: fresh v1/v2 work started before completion rejection, persisted v1/v2 work could not finish after update, same-outcome ACKs rejected, duplicate reports remained pending, and a dropped ACK was not replayed."
|
||||
@@ -13605,7 +13640,7 @@
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "failed",
|
||||
"durationSeconds": 5.86,
|
||||
"summary": "The same byte-identical oracle failed the same 7 of 17 tests on latest main 1136503c6a."
|
||||
@@ -13614,7 +13649,7 @@
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.28,
|
||||
"summary": "The same byte-identical oracle passed all 17 tests on candidate 008f740161, including restart replay and both directions of v1/v2 update compatibility."
|
||||
@@ -13623,7 +13658,7 @@
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "failed",
|
||||
"durationSeconds": 19.84,
|
||||
"summary": "With the claimed production files restored to latest main in 3a15d3ed5d, the same byte-identical oracle returned to the same 7 failures while 10 unaffected cases still passed."
|
||||
@@ -13686,7 +13721,7 @@
|
||||
"date": "2026-07-28",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration-composed-workers.test.ts src/main/runtime/rpc/methods/orchestration-check.test.ts src/main/runtime/rpc/methods/orchestration-send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/cli/handlers/orchestration.test.ts src/cli/handlers/orchestration-check-identity.test.ts src/cli/handlers/orchestration-worker-cli.test.ts src/main/runtime/rpc/methods/orchestration/worker/composed-workers.test.ts src/main/runtime/rpc/methods/orchestration/messaging/check.test.ts src/main/runtime/rpc/methods/orchestration/messaging/send.test.ts src/main/ssh/ssh-remote-orca-cli.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 5.27,
|
||||
"summary": "Five focused files passed with 216 tests, covering visible inactive local worker creation, reveal-failure warnings, stable-pane mailbox routing, live-handle precedence, and SSH fallback parity."
|
||||
@@ -13704,7 +13739,7 @@
|
||||
"date": "2026-08-12",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/federation/federation-lifecycle-settlement.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.58,
|
||||
"summary": "Nine deterministic tests passed for protocol negotiation, Run-home completion and rejection, already-aborted waits, authoritative remote-attachment settlement bound to the exact queued worker_done outcome, and exact verdict replay after lost acknowledgments without mutating durable rejection mail twice."
|
||||
@@ -13713,7 +13748,7 @@
|
||||
"date": "2026-07-28",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration-federation.test.ts",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orchestration/formatter.test.ts src/main/runtime/rpc/methods/orchestration/federation/federation.test.ts",
|
||||
"result": "passed",
|
||||
"durationSeconds": 2.72,
|
||||
"summary": "Two focused files passed with 34 tests, covering authority-aware legacy affordances and federated non-reveal."
|
||||
@@ -13795,21 +13830,21 @@
|
||||
"invariant": "A live Dispatch created by orchestration dispatch can be stopped or abandoned even though it has no supervised worker row. Release must durably record the requested outcome, revoke lifecycle authority, close questions, free the exact assignee identity, and block only the Task whose current Dispatch was released. It must never close the unsupervised terminal process, disturb unrelated or supervised workers, or let a repeat or opposite verb rewrite the persisted outcome.",
|
||||
"oracle": "Create manual, unrelated, and supervised Dispatches through production runtime methods. Require dispatch-show to return the manual id while no worker row exists, then release it and require failed status with exact stopped or abandoned provenance, completion and revocation timestamps, one status notification, zero terminal closes, and immediate redispatch to the same terminal. Repeat through the opposite verb and require the first durable outcome. Create two active contexts for one Task through an explicit ready override, release the older context, and require only its identity to unlock while the newer context and Task remain dispatched. In an isolated Electron runtime, repeat both verbs against one real pane and require the same PTY/incarnation to survive before a third dispatch succeeds.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/cli/handlers/orchestration-worker-cli.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/cli/handlers/orchestration-worker-cli.test.ts --reporter=dot",
|
||||
"pnpm run ensure:electron-runtime && pnpm exec playwright test tests/e2e/orchestration-low-level-dispatch-release.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
|
||||
"SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-low-level-dispatch-release.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts",
|
||||
"src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts",
|
||||
"src/cli/handlers/orchestration-worker-cli.test.ts",
|
||||
"tests/e2e/orchestration-low-level-dispatch-release.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts",
|
||||
"assertions": [
|
||||
"worker-abandon and worker-stop durably release context-only Dispatches without closing terminals",
|
||||
"repeat and cross-verb calls preserve the first stored outcome",
|
||||
@@ -13847,7 +13882,7 @@
|
||||
"date": "2026-08-09",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-manual-dispatch-release.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/rpc/methods/orchestration-workers-recovery.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/cli/handlers/orchestration-worker-cli.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-release.test.ts src/main/runtime/orchestration/orchestration-worker-dispatch-db.test.ts src/main/runtime/rpc/methods/orchestration/worker/workers-recovery.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/cli/handlers/orchestration-worker-cli.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.38,
|
||||
"summary": "Five focused files passed 60 tests, including both context-only release verbs, stale/current ownership, question closure, repeat and cross-verb idempotency, supervised controls, terminal-close negative assertions, and text-mode retained-process guidance."
|
||||
@@ -13920,17 +13955,19 @@
|
||||
"invariant": "A settled Dispatch may close only its one coordinator-created terminal lease. Explicit reuse, real user input, retain, identity or host change, ambiguity, and another resource for the same exact host/pane/process must fence closure. Once the authoritative owning provider positively excludes the resource's exact immutable process incarnation, even an external, user-owned, or transferred dead resource must converge to released without any process close. Unknown host scope, missing incarnation metadata, or unavailable inventory must remain retained. Exact terminal-close persistence must settle when a host partition omits renderer-owned layout state. Output preservation and the requested-to-releasing transition are atomic, archives remain readable without the provider file, retries resume idempotently, and orchestration reset removes archive and authority state.",
|
||||
"oracle": "Record release intent for a settled owner, attempt exact reuse before close, and require worker-start to fail with terminal_release_in_progress while the terminal stays open; then release the original owner exactly once. Race retain and real user input against a controlled archive promise and require no committed archive or close. Rebase a closed web-terminal host partition without terminalLayoutsByTabId and require the persistence write to complete while preserving host-authoritative membership; replay a valid legacy retirement under the same omission and require exact membership removal plus revision advancement. For retained external, user-owned, transferred, stopped, and abandoned resources, run one fresh inventory against the exact local/WSL or SSH provider: an exact live incarnation and every unknown inventory shape stay retained, while positive absence atomically sets ownership_state and release_state to released with processAction none and zero closeTerminal calls. Change host or process identity and inject duplicate resource evidence to require retention. Freeze a structured transcript, delete its source file, and require archived worker-read to return the same bounded redacted messages. Restart a pending mutation, reset orchestration state, and create 50 resources while asserting replay convergence, zero orphan rows, two-query worker listing, and no unrelated close.",
|
||||
"commands": [
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/pty-inventory-liveness-verdict.test.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"pnpm exec vitest run --config config/vitest.config.ts tests/e2e/completed-worker-retirement-resume.unit.test.ts --reporter=verbose",
|
||||
"pnpm run build:cli && SKIP_BUILD=1 pnpm exec playwright test tests/e2e/orchestration-worker-settlement-release-cli.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
|
||||
],
|
||||
"testFiles": [
|
||||
"src/main/runtime/pty-inventory-liveness-verdict.test.ts",
|
||||
"src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts",
|
||||
"src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts",
|
||||
"src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts",
|
||||
"src/main/runtime/rpc/orchestration-mutation-ledger.test.ts",
|
||||
"src/main/runtime/orchestration/worker-transcript-read.test.ts",
|
||||
"src/renderer/src/lib/worker-terminal-takeover-report.test.ts",
|
||||
@@ -13938,6 +13975,14 @@
|
||||
"tests/e2e/orchestration-worker-settlement-release-cli.spec.ts"
|
||||
],
|
||||
"assertionRefs": [
|
||||
{
|
||||
"file": "src/main/runtime/pty-inventory-liveness-verdict.test.ts",
|
||||
"assertions": [
|
||||
"320 simultaneously live PTYs retain truthful verdicts with linear identity checks and no detached history",
|
||||
"400 unresolved PTY retirements preserve active doubt while bounding history at 256 entries",
|
||||
"a replacement lifecycle clears the retained historical verdict for the reused PTY id"
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts",
|
||||
"assertions": [
|
||||
@@ -13961,7 +14006,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-worker-release.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts",
|
||||
"assertions": [
|
||||
"reconciles a dead external terminal without closing a process",
|
||||
"reconciles a dead user-taken-over terminal without closing a process",
|
||||
@@ -13980,7 +14025,7 @@
|
||||
"assertions": ["resumes a pending idempotent worker release after restart"]
|
||||
},
|
||||
{
|
||||
"file": "src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts",
|
||||
"file": "src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts",
|
||||
"assertions": [
|
||||
"finishes a requested release after restart-style interruption",
|
||||
"coalesces overlapping reconciliation passes and closes each resource once",
|
||||
@@ -14002,7 +14047,7 @@
|
||||
"date": "2026-08-27",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/mobile-session-terminal-persistence-retirement.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 8.78,
|
||||
"summary": "Seven deterministic files passed 78 tests, including red-green host-partition rebase and legacy-retirement regressions with an absent web-terminal layout map plus exact lease, reuse, takeover, recovery, restart, archive, and accounting contracts."
|
||||
@@ -14020,7 +14065,7 @@
|
||||
"date": "2026-08-11",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-process-incarnation-liveness.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 4.98,
|
||||
"summary": "Six focused files passed 67 tests on the rebased candidate, covering dead external, user-owned, stopped, abandoned, and transferred reconciliation; exact local/WSL/SSH provider routing; malformed, missing, and unavailable inventory retention; zero process closes; existing lease, archive, recovery, mutation, and renderer-input contracts."
|
||||
@@ -14029,7 +14074,7 @@
|
||||
"date": "2026-08-03",
|
||||
"runner": "local",
|
||||
"platform": "macos",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration-worker-release.test.ts src/main/runtime/rpc/methods/orchestration-worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"command": "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release.test.ts src/main/runtime/rpc/methods/orchestration/worker/worker-release-recovery.test.ts src/main/runtime/rpc/orchestration-mutation-ledger.test.ts src/main/runtime/orchestration/worker-transcript-read.test.ts src/renderer/src/lib/worker-terminal-takeover-report.test.ts --reporter=dot",
|
||||
"result": "passed",
|
||||
"durationSeconds": 3.48,
|
||||
"summary": "Five focused files passed 56 tests covering lease serialization, reminted-handle transfer, duplicate-identity fencing, retain and takeover races, immutable archives, conservative legacy migration, mutation restart, reset cleanup, bounded accounting, and renderer input reporting."
|
||||
@@ -14045,11 +14090,11 @@
|
||||
},
|
||||
"redGreenEvidence": {
|
||||
"status": "complete",
|
||||
"evidence": "The version-skew legacy-retirement test deterministically threw at mobile-session-terminal-persistence-retirement.ts:75 before the null-safe layout read and passed with exact tab removal, tombstone cleanup, and topology-revision advancement after the fix. The byte-identical compiled-CLI Electron oracle left the dead resource external/retained on latest main 5ea7df1a5b, passed on combined candidate d697666ce8 with released/released SQLite state and processAction none, and reproduced external/retained after disabling the claimed production files at merge-base 64aec94cb2. The earlier unchanged three-case dead external/user-owned/transferred service oracle likewise failed 3/3 on main, passed 3/3 on candidate, and failed 3/3 with production restored; every run asserted durable state and zero terminal close calls."
|
||||
"evidence": "The version-skew legacy-retirement test deterministically threw at mobile-session-terminal-persistence-retirement.ts:75 before the null-safe layout read and passed with exact tab removal, tombstone cleanup, and topology-revision advancement after the fix. The byte-identical compiled-CLI Electron oracle left the dead resource external/retained on latest main 5ea7df1a5b, passed on combined candidate d697666ce8 with released/released SQLite state and processAction none, and reproduced external/retained after disabling the claimed production files at merge-base 64aec94cb2. The earlier unchanged three-case dead external/user-owned/transferred service oracle likewise failed 3/3 on main, passed 3/3 on candidate, and failed 3/3 with production restored; every run asserted durable state and zero terminal close calls. The 320-live-PTY oracle failed on the prior single-map implementation and passes with complete active evidence, zero detached history, and a linear identity-check bound after the cache split."
|
||||
},
|
||||
"performanceBudget": {
|
||||
"required": true,
|
||||
"evidence": "Normal owned release performs constant-count indexed resource and identity queries plus one bounded archive capture. Missing layout maps use constant-time empty-record fallbacks inside the existing explicit persistence pass, with no added scan or allocation proportional to terminal history. A retained release performs exactly one bounded inventory against its authoritative local/WSL or specific SSH provider, with no retry, polling, timer, subprocess, renderer subscription, or per-session follow-up fanout. Worker-list uses two set queries rather than one resource lookup per worker."
|
||||
"evidence": "Normal owned release performs constant-count indexed resource and identity queries plus one bounded archive capture. Missing layout maps use constant-time empty-record fallbacks inside the existing explicit persistence pass, with no added scan or allocation proportional to terminal history. A retained release performs exactly one bounded inventory against its authoritative local/WSL or specific SSH provider, with no retry, polling, timer, subprocess, renderer subscription, or per-session follow-up fanout. Each liveness observation performs constant-time active-identity classification; retirement performs one historical insertion and at most one oldest-entry eviction, while active evidence scales only with supported PTYs and detached history is capped at 256. Worker-list uses two set queries rather than one resource lookup per worker."
|
||||
},
|
||||
"promotionCriteria": [
|
||||
"Collect 100 consecutive focused CI passes or 14 days of soak history.",
|
||||
|
||||
@@ -101,29 +101,112 @@ function constantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_MARKDOWN`
|
||||
}
|
||||
|
||||
function serializeEmbeddedModule(guides) {
|
||||
const markdownConstants = guides
|
||||
function fullConstantName(name) {
|
||||
return `${name.replace(/-/g, '_').toUpperCase()}_FULL_MARKDOWN`
|
||||
}
|
||||
|
||||
function referenceConstantName(guideName, referenceName) {
|
||||
return `${`${guideName}_${referenceName}`.replace(/-/g, '_').toUpperCase()}_REFERENCE_MARKDOWN`
|
||||
}
|
||||
|
||||
function composeFullMarkdown(markdown, references) {
|
||||
if (references.length === 0) {
|
||||
return markdown
|
||||
}
|
||||
const packageHeader =
|
||||
'\n\n---\n\n# Bundled references\n\n' +
|
||||
'These references belong to the version-matched guide above. Read only the documents ' +
|
||||
'named by its action gates.\n'
|
||||
const documents = references
|
||||
.map(
|
||||
(guide) =>
|
||||
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
|
||||
({ relativePath, markdown: referenceMarkdown }) =>
|
||||
`\n<!-- bundled-reference: ${relativePath} -->\n\n${referenceMarkdown.trimEnd()}\n`
|
||||
)
|
||||
.join('')
|
||||
return `${markdown.trimEnd()}${packageHeader}${documents}`
|
||||
}
|
||||
|
||||
function serializeEmbeddedModule(guides) {
|
||||
const referenceConstants = guides.flatMap((guide) =>
|
||||
guide.references.map((reference) => referenceConstantName(guide.name, reference.name))
|
||||
)
|
||||
// Why: the constant name flattens guide and reference names, so two topics could otherwise
|
||||
// produce one identifier and silently serve the wrong reference.
|
||||
if (new Set(referenceConstants).size !== referenceConstants.length) {
|
||||
throw new Error(`Guide reference constant names collide: ${referenceConstants.join(', ')}`)
|
||||
}
|
||||
const markdownConstants = guides
|
||||
.flatMap((guide) => {
|
||||
const constants = [
|
||||
`// oxfmt-ignore\nconst ${constantName(guide.name)} = ${JSON.stringify(guide.markdown)}`
|
||||
]
|
||||
if (guide.fullMarkdown !== guide.markdown) {
|
||||
constants.push(
|
||||
`// oxfmt-ignore\nconst ${fullConstantName(guide.name)} = ${JSON.stringify(guide.fullMarkdown)}`
|
||||
)
|
||||
}
|
||||
for (const reference of guide.references) {
|
||||
constants.push(
|
||||
`// oxfmt-ignore\nconst ${referenceConstantName(guide.name, reference.name)} = ${JSON.stringify(reference.markdown)}`
|
||||
)
|
||||
}
|
||||
return constants
|
||||
})
|
||||
.join('\n\n')
|
||||
const guideEntries = guides
|
||||
.map((guide) => {
|
||||
const markdownConstant = constantName(guide.name)
|
||||
const referenceEntries = guide.references
|
||||
.map(
|
||||
(reference) =>
|
||||
`{ name: ${JSON.stringify(reference.name)}, markdown: ${referenceConstantName(guide.name, reference.name)} }`
|
||||
)
|
||||
.join(', ')
|
||||
return [
|
||||
' {',
|
||||
` name: ${JSON.stringify(guide.name)},`,
|
||||
` description: ${JSON.stringify(guide.description)},`,
|
||||
` markdown: ${markdownConstant},`,
|
||||
` fullMarkdown: ${markdownConstant},`,
|
||||
` aliases: ${JSON.stringify(guide.aliases)}`,
|
||||
` fullMarkdown: ${guide.fullMarkdown === guide.markdown ? markdownConstant : fullConstantName(guide.name)},`,
|
||||
` aliases: ${JSON.stringify(guide.aliases)},`,
|
||||
` references: [${referenceEntries}]`,
|
||||
' }'
|
||||
].join('\n')
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n}\n\n${markdownConstants}\n\n// Why: no current guide has bundled reference documents, so --full is byte-identical for now.\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
return `// Generated by config/scripts/generate-bundled-skill-guides.mjs. Do not edit.\n\nexport type BundledSkillGuideReference = {\n readonly name: string\n readonly markdown: string\n}\n\nexport type BundledSkillGuide = {\n readonly name: string\n readonly description: string\n readonly markdown: string\n readonly fullMarkdown: string\n readonly aliases: readonly string[]\n readonly references: readonly BundledSkillGuideReference[]\n}\n\n${markdownConstants}\n\n// oxfmt-ignore\nexport const BUNDLED_SKILL_GUIDES = [\n${guideEntries}\n] as const satisfies readonly BundledSkillGuide[]\n`
|
||||
}
|
||||
|
||||
async function readGuideReferences(repoRoot, guideName) {
|
||||
const referenceRoot = path.join(repoRoot, 'skill-guides', guideName, 'references')
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(referenceRoot, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const unsupported = entries.find((entry) => !entry.isFile() || !entry.name.endsWith('.md'))
|
||||
if (unsupported) {
|
||||
throw new Error(
|
||||
`Guide references must be Markdown files: skill-guides/${guideName}/references/${unsupported.name}`
|
||||
)
|
||||
}
|
||||
return Promise.all(
|
||||
entries
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'en'))
|
||||
.map(async (entry) => {
|
||||
const sourcePath = path.join(referenceRoot, entry.name)
|
||||
const markdown = normalizeMarkdown(await readFile(sourcePath, 'utf8'))
|
||||
if (!markdown.trim()) {
|
||||
throw new Error(`Guide reference is empty: ${toPosixRelativePath(repoRoot, sourcePath)}`)
|
||||
}
|
||||
return { name: entry.name.slice(0, -3), relativePath: `references/${entry.name}`, markdown }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function assertAliasContract(guides) {
|
||||
@@ -204,9 +287,22 @@ async function buildArtifacts(repoRoot = REPO_ROOT) {
|
||||
throw new Error(`Guide source ${name}.md declares mismatched name ${frontmatter.name}`)
|
||||
}
|
||||
const aliases = GUIDE_ALIASES[name]
|
||||
const references = await readGuideReferences(repoRoot, name)
|
||||
// Why: the embedded table always carries the full guide (served by `skills get`);
|
||||
// only the installable projection thins to a stub once a topic is in STUB_TOPICS.
|
||||
guides.push({ name, description: frontmatter.description, markdown, aliases })
|
||||
guides.push({
|
||||
name,
|
||||
description: frontmatter.description,
|
||||
markdown,
|
||||
fullMarkdown: composeFullMarkdown(markdown, references),
|
||||
aliases,
|
||||
// Why: `skills get --reference` serves one of these alone, so it keeps the
|
||||
// per-file identity that fullMarkdown's concatenation erases.
|
||||
references: references.map(({ name: referenceName, markdown: referenceMarkdown }) => ({
|
||||
name: referenceName,
|
||||
markdown: referenceMarkdown
|
||||
}))
|
||||
})
|
||||
const stubPath = path.join(repoRoot, 'skill-stubs', `${name}.md`)
|
||||
const content = stubTopics.has(name)
|
||||
? composeStubProjection(markdown, await readFile(stubPath, 'utf8'), `skill-stubs/${name}.md`)
|
||||
@@ -273,6 +369,7 @@ export {
|
||||
STUB_TOPICS,
|
||||
assertAliasContract,
|
||||
buildArtifacts,
|
||||
composeFullMarkdown,
|
||||
composeStubProjection,
|
||||
frontmatterBlock,
|
||||
normalizeMarkdown,
|
||||
|
||||
@@ -22,6 +22,15 @@ import {
|
||||
const projectDir = path.resolve(import.meta.dirname, '..', '..')
|
||||
const temporaryDirectories = []
|
||||
const execFileAsync = promisify(execFile)
|
||||
const ORCHESTRATION_REFERENCES = [
|
||||
'coordinator-loop.md',
|
||||
'legacy-contract-migration.md',
|
||||
'low-level-topology.md',
|
||||
'messaging-and-gates.md',
|
||||
'placement-and-remote.md',
|
||||
'recovery-and-cleanup.md',
|
||||
'worker-contract.md'
|
||||
]
|
||||
|
||||
async function createFixture() {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-bundled-skill-guides-'))
|
||||
@@ -181,7 +190,7 @@ describe('bundled skill guide generator', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it('embeds canonical names, discovery descriptions, Markdown, and append-only aliases', async () => {
|
||||
it('embeds compact guides, version-matched reference packages, and append-only aliases', async () => {
|
||||
expect(BUNDLED_SKILL_GUIDES.map((guide) => guide.name)).toEqual(
|
||||
[...CANONICAL_GUIDE_NAMES].sort((left, right) => left.localeCompare(right, 'en'))
|
||||
)
|
||||
@@ -194,8 +203,46 @@ describe('bundled skill guide generator', () => {
|
||||
const frontmatter = parseFrontmatter(source, `${guide.name}.md`)
|
||||
expect(guide.description).toBe(frontmatter.description)
|
||||
expect(guide.markdown).toBe(source)
|
||||
expect(guide.fullMarkdown).toBe(source)
|
||||
expect(guide.aliases).toEqual(GUIDE_ALIASES[guide.name])
|
||||
if (guide.name !== 'orchestration') {
|
||||
expect(guide.fullMarkdown).toBe(source)
|
||||
expect(guide.references).toEqual([])
|
||||
continue
|
||||
}
|
||||
// Why: the per-reference selector serves these verbatim, so an entry that
|
||||
// drifts from the file on disk ships a stale reference to every agent.
|
||||
expect(guide.references.map((reference) => reference.name)).toEqual(
|
||||
ORCHESTRATION_REFERENCES.map((reference) => reference.replace(/\.md$/u, ''))
|
||||
)
|
||||
for (const reference of guide.references) {
|
||||
expect(reference.markdown).toBe(
|
||||
normalizeMarkdown(
|
||||
await readFile(
|
||||
path.join(
|
||||
projectDir,
|
||||
'skill-guides',
|
||||
'orchestration',
|
||||
'references',
|
||||
`${reference.name}.md`
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
expect(guide.fullMarkdown).not.toBe(guide.markdown)
|
||||
expect(guide.fullMarkdown.length).toBeGreaterThan(guide.markdown.length)
|
||||
expect(guide.fullMarkdown.startsWith(source.trimEnd())).toBe(true)
|
||||
for (const reference of ORCHESTRATION_REFERENCES) {
|
||||
const marker = `<!-- bundled-reference: references/${reference} -->`
|
||||
expect(guide.fullMarkdown.split(marker)).toHaveLength(2)
|
||||
expect(guide.fullMarkdown).toContain(
|
||||
await readFile(
|
||||
path.join(projectDir, 'skill-guides', 'orchestration', 'references', reference),
|
||||
'utf8'
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -237,6 +284,17 @@ describe('bundled skill guide generator', () => {
|
||||
const stubSource = await readFile(stubPath, 'utf8')
|
||||
await writeFile(stubPath, stubSource.replaceAll('\n', '\r\n'))
|
||||
}
|
||||
for (const reference of ORCHESTRATION_REFERENCES) {
|
||||
const referencePath = path.join(
|
||||
root,
|
||||
'skill-guides',
|
||||
'orchestration',
|
||||
'references',
|
||||
reference
|
||||
)
|
||||
const source = await readFile(referencePath, 'utf8')
|
||||
await writeFile(referencePath, source.replaceAll('\n', '\r\n'))
|
||||
}
|
||||
|
||||
const actual = await buildArtifacts(root)
|
||||
expect(actual.map((artifact) => artifact.content)).toEqual(
|
||||
@@ -303,4 +361,15 @@ describe('bundled skill guide generator', () => {
|
||||
])
|
||||
).toThrow('collides with canonical name')
|
||||
})
|
||||
|
||||
it('rejects non-Markdown and empty bundled references', async () => {
|
||||
const root = await createFixture()
|
||||
const referenceRoot = path.join(root, 'skill-guides', 'orchestration', 'references')
|
||||
|
||||
await writeFile(path.join(referenceRoot, 'notes.txt'), 'not a reference\n')
|
||||
await expect(buildArtifacts(root)).rejects.toThrow('Guide references must be Markdown files')
|
||||
await rm(path.join(referenceRoot, 'notes.txt'))
|
||||
await writeFile(path.join(referenceRoot, 'empty.md'), '\n')
|
||||
await expect(buildArtifacts(root)).rejects.toThrow('Guide reference is empty')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,7 +10,14 @@ const guidePath = join(projectDir, 'skill-guides', 'orca-cli.md')
|
||||
const stubPath = join(projectDir, 'skills', 'orca-cli', 'SKILL.md')
|
||||
// Why: orchestration and orca-emulator also ship hybrid stubs now, so their version-sensitive
|
||||
// command guidance lives in the guide sources — read the cross-guide worktree-id contract there.
|
||||
const orchestrationSkillPath = join(projectDir, 'skill-guides', 'orchestration.md')
|
||||
// Why: the worktree-selector rule lives in the orchestration placement reference, not the kernel.
|
||||
const orchestrationPlacementPath = join(
|
||||
projectDir,
|
||||
'skill-guides',
|
||||
'orchestration',
|
||||
'references',
|
||||
'placement-and-remote.md'
|
||||
)
|
||||
const emulatorSkillPath = join(projectDir, 'skill-guides', 'orca-emulator.md')
|
||||
|
||||
function readSkill(path = guidePath) {
|
||||
@@ -95,7 +102,7 @@ describe('orca CLI skill guidance', () => {
|
||||
|
||||
it('requires full worktree ids across bundled agent guidance', () => {
|
||||
const cliSkill = readSkill()
|
||||
const orchestrationSkill = readSkill(orchestrationSkillPath)
|
||||
const orchestrationSkill = readSkill(orchestrationPlacementPath)
|
||||
const emulatorSkill = readSkill(emulatorSkillPath)
|
||||
|
||||
for (const skill of [cliSkill, orchestrationSkill, emulatorSkill]) {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ORCHESTRATION_COMMAND_SPECS } from '../../src/cli/specs/orchestration'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
const guideRoot = join(projectDir, 'skill-guides', 'orchestration')
|
||||
const guidePaths = [
|
||||
join(projectDir, 'skill-guides', 'orchestration.md'),
|
||||
...readdirSync(join(guideRoot, 'references')).map((name) => join(guideRoot, 'references', name))
|
||||
]
|
||||
|
||||
function documentedInvocations() {
|
||||
return guidePaths.flatMap((path) => {
|
||||
const text = readFileSync(path, 'utf8')
|
||||
return [...text.matchAll(/ORCA orchestration ([a-z-]+)([^`\n]*)/gu)].map((match) => ({
|
||||
path,
|
||||
verb: match[1],
|
||||
flags: [...match[2].matchAll(/(?:^|\s)--([a-z][a-z-]*)/gu)].map((flag) => flag[1])
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
describe('orchestration guide command contract', () => {
|
||||
it('documents only orchestration verbs and flags accepted by the CLI specs', () => {
|
||||
const specs = new Map(
|
||||
ORCHESTRATION_COMMAND_SPECS.map((spec) => [spec.path[1], new Set(spec.allowedFlags)])
|
||||
)
|
||||
|
||||
for (const invocation of documentedInvocations()) {
|
||||
const allowed = specs.get(invocation.verb)
|
||||
expect(allowed, `${invocation.path}: ${invocation.verb}`).toBeDefined()
|
||||
for (const flag of invocation.flags) {
|
||||
expect(allowed, `${invocation.path}: ${invocation.verb} --${flag}`).toContain(flag)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,32 +1,58 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const projectDir = resolve(import.meta.dirname, '../..')
|
||||
// Why: orchestration now ships a hybrid discovery stub, so its version-sensitive command
|
||||
// guidance lives in the authoritative guide source — assert that content there. The
|
||||
// installable stub projection is checked separately below.
|
||||
const guidePath = join(projectDir, 'skill-guides', 'orchestration.md')
|
||||
const referenceRoot = join(projectDir, 'skill-guides', 'orchestration', 'references')
|
||||
const stubPath = join(projectDir, 'skills', 'orchestration', 'SKILL.md')
|
||||
|
||||
function readSkill() {
|
||||
function readKernel() {
|
||||
return readFileSync(guidePath, 'utf8')
|
||||
}
|
||||
|
||||
function getSection(markdown, heading) {
|
||||
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const match = markdown.match(
|
||||
new RegExp(`## ${escapedHeading}\\r?\\n([\\s\\S]*?)(?=\\r?\\n## |$)`)
|
||||
)
|
||||
|
||||
expect(match).not.toBeNull()
|
||||
|
||||
return match?.[1] ?? ''
|
||||
function readReference(name) {
|
||||
return readFileSync(join(referenceRoot, name), 'utf8')
|
||||
}
|
||||
|
||||
describe('orchestration skill guidance', () => {
|
||||
function frontmatter(text) {
|
||||
return /^---\n[\s\S]*?\n---\n/u.exec(text)?.[0]
|
||||
}
|
||||
|
||||
function squash(text) {
|
||||
return text.replace(/\s+/gu, ' ').trim()
|
||||
}
|
||||
|
||||
// Routing lives in the frontmatter description alone; the body must not satisfy these.
|
||||
function readDescription() {
|
||||
return squash(frontmatter(readKernel()))
|
||||
}
|
||||
|
||||
describe('orchestration skill routing', () => {
|
||||
it('keeps the verbatim routing triggers a model matches the skill on', () => {
|
||||
const description = readDescription()
|
||||
|
||||
for (const trigger of [
|
||||
'threaded messages',
|
||||
'worker_done/escalation waits',
|
||||
'decision gates',
|
||||
'decomposing work across agents',
|
||||
'"hand off"',
|
||||
'"handoff"',
|
||||
'"handover"',
|
||||
'"give this to another agent"',
|
||||
'"another worktree"',
|
||||
'lightweight terminal prompts',
|
||||
'shell commands',
|
||||
'Orca worktree management',
|
||||
'reading or waiting on terminals'
|
||||
]) {
|
||||
expect(description).toContain(trigger)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps external browser routing at the OS/page boundary', () => {
|
||||
const description = readFileSync(guidePath, 'utf8').replace(/\s+/gu, ' ')
|
||||
const description = readDescription()
|
||||
|
||||
expect(description).toContain(
|
||||
"Use Computer Use for external browser windows, webviews, Orca app UI, or desktop UI outside Orca's embedded browser only when the task requires OS/window-level control such as focus, menus, dialogs, coordinates, or screenshots."
|
||||
@@ -35,383 +61,444 @@ describe('orchestration skill guidance', () => {
|
||||
"`orca-cli` for Orca's embedded pages and a page-automation tool such as Playwright or CDP for external pages."
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('requires Orca runtime state before claiming a worker was orchestrated', () => {
|
||||
const skill = readSkill()
|
||||
const toolBoundary = getSection(skill, 'Tool Boundary')
|
||||
describe('orchestration kernel', () => {
|
||||
it('keeps the always-loaded guide compact and ordered around the normal protocol', () => {
|
||||
const kernel = readKernel()
|
||||
const headings = [
|
||||
'## Outcome',
|
||||
'## Classify the role',
|
||||
'## Authority and safety floor',
|
||||
'## Worker obligations',
|
||||
'## Canonical supervised loop',
|
||||
'## Task-spec contract',
|
||||
'## Completion accounting',
|
||||
'## Conditional references'
|
||||
]
|
||||
|
||||
expect(toolBoundary).toContain('must create or bind a Run')
|
||||
expect(toolBoundary).toContain('create the Task with `orca orchestration task-create`')
|
||||
expect(toolBoundary).toContain('preferred `orca orchestration worker-start` composition')
|
||||
expect(toolBoundary).toContain('low-level `orca orchestration dispatch --inject` path')
|
||||
expect(toolBoundary).not.toContain('or `orca orchestration run`')
|
||||
expect(skill).toContain(
|
||||
'`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands'
|
||||
)
|
||||
expect(toolBoundary).toContain(
|
||||
'Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features'
|
||||
)
|
||||
expect(toolBoundary).toContain('do not create Orca task/dispatch provenance')
|
||||
expect(toolBoundary).toContain('injected lifecycle preambles')
|
||||
expect(toolBoundary).toContain('`worker_done` authority')
|
||||
expect(toolBoundary).toContain('decision gates')
|
||||
expect(toolBoundary).toContain('orca orchestration task-list --json')
|
||||
expect(toolBoundary).toContain('orca orchestration dispatch-show --task <task_id> --json')
|
||||
expect(toolBoundary).toContain(
|
||||
'do not retroactively describe the external worker as orchestrated'
|
||||
)
|
||||
})
|
||||
|
||||
it('teaches attested adoption without reviving the retired scheduler', () => {
|
||||
const skill = readSkill()
|
||||
const migration = getSection(skill, 'Contract Migration')
|
||||
|
||||
expect(migration).toContain(
|
||||
'adopts a live pre-update orchestration assignment into an ordinary Run'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch'
|
||||
)
|
||||
expect(migration).toContain('never restarts or replaces the worker')
|
||||
expect(migration).toContain('The retired scheduler is not revived')
|
||||
expect(migration).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(migration).toContain('[LEGACY READ-ONLY]')
|
||||
expect(migration).toContain(
|
||||
'Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.'
|
||||
)
|
||||
expect(migration).toContain(
|
||||
'It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal.'
|
||||
)
|
||||
expect(migration).not.toContain('task-list --run run_legacy_local')
|
||||
expect(migration).toContain('run_legacy_local is an empty audit tombstone')
|
||||
expect(migration).toContain('Recovered orchestration work from a contract update')
|
||||
expect(migration).toContain('run-show --id <adopted_run_id>')
|
||||
expect(migration).toContain('task-list --run <adopted_run_id>')
|
||||
expect(migration).toContain('Legacy inspection remains available without consuming mail')
|
||||
expect(migration).toContain('run-use --id <adopted_run_id> --takeover-legacy')
|
||||
expect(migration).toContain('Takeover fences only the old coordinator')
|
||||
expect(migration).toContain('Live legacy workers keep their original Tasks, Dispatches')
|
||||
expect(migration).toContain(
|
||||
'keep the original worker as the only editor until it reaches a stable handoff point'
|
||||
)
|
||||
expect(migration).toContain('a conflict-free placement for any remaining work')
|
||||
})
|
||||
|
||||
it('treats long-running worker waits as liveness checkpoints, not failures', () => {
|
||||
const skill = readSkill()
|
||||
|
||||
expect(skill).toContain('Treat a `check --wait` timeout or `{count:0}` as a checkpoint')
|
||||
expect(skill).toContain('Do not stop, close, kill, or restart a worker')
|
||||
expect(skill).toContain('keep waiting instead of retrying the task')
|
||||
expect(skill).not.toContain(
|
||||
'If `check --wait` times out with no `worker_done` or `escalation`, fall back to `terminal wait --for tui-idle`, then `terminal read`.'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps full handoffs out of dispatch lifecycle and off the active branch base', () => {
|
||||
const skill = readSkill()
|
||||
const fullHandoffs = getSection(skill, 'Full Handoffs')
|
||||
|
||||
expect(skill).toContain('Full handoff means ownership transfer, not supervised dispatch.')
|
||||
expect(fullHandoffs).toContain(
|
||||
'Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs.'
|
||||
)
|
||||
expect(fullHandoffs).toContain(
|
||||
'`task-create` is also forbidden because it records coordinator-owned tracking state'
|
||||
)
|
||||
expect(fullHandoffs).toContain('Do not create a `taskId`/`dispatchId`')
|
||||
expect(fullHandoffs).toContain(
|
||||
'read the worker terminal after prompt delivery except to avoid losing the initial prompt'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'`--no-parent` only controls Orca lineage; it does not choose the Git base.'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'never base it on the current feature branch unless the user explicitly asks'
|
||||
)
|
||||
expect(skill).toContain(
|
||||
'orca worktree create --name <task-name> --no-parent --agent codex --prompt'
|
||||
)
|
||||
expect(fullHandoffs).toContain(
|
||||
'Before creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level'
|
||||
)
|
||||
expect(fullHandoffs).toContain(
|
||||
'Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree'
|
||||
)
|
||||
expect(fullHandoffs).toContain(
|
||||
'For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`'
|
||||
)
|
||||
expect(fullHandoffs).toContain('If the work should start from the repo default base')
|
||||
expect(fullHandoffs).toContain('omit `--base-branch`')
|
||||
})
|
||||
|
||||
it('classifies handoff wording as ownership transfer unless supervision is explicit', () => {
|
||||
const skill = readSkill()
|
||||
const fullHandoffs = getSection(skill, 'Full Handoffs')
|
||||
|
||||
for (const phrase of [
|
||||
'hand off',
|
||||
'handoff',
|
||||
'handover',
|
||||
'give this to another agent',
|
||||
'give this to another worktree',
|
||||
'another agent',
|
||||
'another worktree'
|
||||
]) {
|
||||
expect(fullHandoffs).toContain(phrase)
|
||||
// Why: 202 is the budget after the anti-loop nextAction rule; the kernel is always in context.
|
||||
expect(kernel.split('\n').length).toBeLessThanOrEqual(202)
|
||||
for (let index = 1; index < headings.length; index += 1) {
|
||||
expect(kernel.indexOf(headings[index])).toBeGreaterThan(kernel.indexOf(headings[index - 1]))
|
||||
}
|
||||
expect(kernel).not.toContain('## Contract Migration')
|
||||
expect(kernel).not.toContain('## Full Handoffs')
|
||||
expect(kernel).not.toContain('## Worker Terminals')
|
||||
})
|
||||
|
||||
for (const supervisionPhrase of [
|
||||
'supervise',
|
||||
'monitor',
|
||||
'wait for worker_done',
|
||||
'wait for results',
|
||||
'track completion',
|
||||
'DAG',
|
||||
'decision gate',
|
||||
'ask/reply'
|
||||
it('classifies coordinator, dispatched worker, handoff, compatibility, and ordinary roles', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
expect(kernel).toContain('explicitly asks to supervise, monitor, wait for results')
|
||||
expect(kernel).toContain('live injected preamble with Task and Dispatch IDs')
|
||||
expect(kernel).toContain('Handoff owner')
|
||||
expect(kernel).toContain('create no Run, Task, or Dispatch and do not monitor completion')
|
||||
expect(kernel).toContain('Compatibility operator')
|
||||
expect(kernel).toContain('Ordinary terminal agent')
|
||||
expect(kernel).toContain('Model or effort selection does not make a handoff supervised')
|
||||
expect(squash(kernel)).toContain('Never substitute a non-Orca subagent tool')
|
||||
})
|
||||
|
||||
it('makes Dispatch identity, remote uncertainty, folders, and mixed versions a safety floor', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
expect(kernel).toContain('A Dispatch is one authoritative Task attempt')
|
||||
expect(kernel).toContain('Lifecycle authority comes from the active Dispatch')
|
||||
expect(kernel).toContain('execution host owns')
|
||||
expect(squash(kernel)).toContain('`live` / `unverifiable` / `exited`')
|
||||
expect(kernel).toContain('contact loss is not process death')
|
||||
expect(kernel).toContain('Folder workspaces are valid')
|
||||
expect(squash(kernel)).toContain('Treat unknown optional fields as absent')
|
||||
expect(kernel).toContain('new stream operation requires advertised capability')
|
||||
expect(kernel).toContain('Never fall back to local execution')
|
||||
})
|
||||
|
||||
it('puts exactly-once worker completion and post-completion idle before coordinator mechanics', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
expect(kernel.indexOf('## Worker obligations')).toBeLessThan(
|
||||
kernel.indexOf('## Canonical supervised loop')
|
||||
)
|
||||
expect(kernel).toContain('The injected preamble is authoritative')
|
||||
expect(kernel).toContain('Send `worker_done` exactly once')
|
||||
expect(kernel).toContain('three-sentence executive summary')
|
||||
expect(kernel).toContain('`--outcome succeeded` or `--outcome failed`')
|
||||
// Why: the runnable worker_done command is the preamble's; its flag spellings are pinned
|
||||
// on worker-contract.md by 'keeps heartbeat and worker_done recipes bound to the injected
|
||||
// capability', so the kernel carries the obligations as prose and no third copy.
|
||||
expect(kernel).not.toContain('--type worker_done')
|
||||
expect(kernel).toContain('After `worker_done`, end the dispatched turn and idle')
|
||||
expect(kernel).toContain('Do not reuse the settled lifecycle IDs')
|
||||
})
|
||||
|
||||
it('teaches worker-start as the only normal-path launch and starts the wave before waiting', () => {
|
||||
const kernel = readKernel()
|
||||
const firstStart = kernel.indexOf('worker-start --spec "<worker A task>"')
|
||||
const secondStart = kernel.indexOf('worker-start --spec "<worker B task>"')
|
||||
const firstWait = kernel.indexOf('check --wait')
|
||||
|
||||
expect(firstStart).toBeGreaterThan(kernel.indexOf('run-create'))
|
||||
expect(secondStart).toBeGreaterThan(firstStart)
|
||||
expect(firstWait).toBeGreaterThan(secondStart)
|
||||
expect(squash(kernel)).toContain('start the full independent wave before waiting')
|
||||
expect(kernel).toContain('`worker-start` is the normal path')
|
||||
expect(squash(kernel)).toContain(
|
||||
"If `worker-start` exits non-zero, do not relaunch. Read the receipt's `failedStage` and `residualResources`"
|
||||
)
|
||||
expect(kernel).toContain('operator-created process unsupervised')
|
||||
expect(kernel).not.toMatch(/^ORCA terminal create/mu)
|
||||
})
|
||||
|
||||
it('makes worker-start --spec the default and keeps task-create for planned fan-out', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain('`worker-start --spec` creates the Task and its attempt in one call')
|
||||
expect(kernel).toContain('Use `task-create` plus `worker-start --task <task_id>`')
|
||||
})
|
||||
|
||||
it('gives the supervised loop an exit condition for a live terminal with a dead agent', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain("`worker-list`'s `projection.liveness` is the fleet verdict")
|
||||
expect(kernel).toContain("`worker-show`'s `observation.status` is PTY liveness only")
|
||||
expect(kernel).toContain('After three consecutive empty waits')
|
||||
expect(kernel).toContain('`ORCA orchestration worker-list --include-remote --json`')
|
||||
expect(kernel).toContain('defaults to the bound Run; `--run <run_id>` overrides')
|
||||
expect(kernel).toContain(
|
||||
'`projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv'
|
||||
)
|
||||
expect(kernel).toContain(
|
||||
'An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false is informational, not a command to re-run: keep waiting with `check --wait`'
|
||||
)
|
||||
expect(kernel).toContain('choose `worker-stop` or `worker-abandon`')
|
||||
})
|
||||
|
||||
it('lets only positive evidence of exit end a wait', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain('Leave the wait only on positive proof the agent stopped')
|
||||
expect(kernel).toContain('`exited` liveness')
|
||||
expect(kernel).toContain("the worker's own observation of process exit")
|
||||
expect(kernel).toContain('transcript whose final agent turn sent no `worker_done`')
|
||||
expect(kernel).toContain(
|
||||
'`unverifiable` is absence, including when `worker-show` reports `agentWait` null. Absence never authorizes stop, abandon, retry, or release'
|
||||
)
|
||||
})
|
||||
|
||||
it('names --terminal, never --from, as the check caller flag', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain('`check` names its caller with `--terminal <handle>`, never `--from`')
|
||||
expect(kernel).not.toContain('check --from')
|
||||
})
|
||||
|
||||
it('makes a dispatched worker read coordinator follow-ups on a cadence', () => {
|
||||
const kernel = squash(readKernel())
|
||||
|
||||
expect(kernel).toContain('Read coordinator follow-ups at each natural checkpoint')
|
||||
expect(kernel).toContain('once more immediately before `worker_done`')
|
||||
expect(kernel).toContain('`ORCA orchestration check --terminal <your_handle> --json`')
|
||||
})
|
||||
|
||||
it('requires full Delivery processing and settled-terminal accounting before ack', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
expect(squash(kernel)).toContain(
|
||||
'oldest FIFO Delivery and replays that batch until acknowledged'
|
||||
)
|
||||
expect(squash(kernel)).toContain('Process every message')
|
||||
expect(squash(kernel)).toContain("decide each settled terminal's next owner before the ack")
|
||||
expect(squash(kernel)).toContain('reused, explicitly retained, or released')
|
||||
expect(squash(kernel)).toContain(
|
||||
'the turn ends only when the report to that user names, per Task, its outcome, the evidence behind it, and any unresolved blocker'
|
||||
)
|
||||
expect(kernel).toContain('worker-release --dispatch <dispatch_id>')
|
||||
expect(kernel).toContain('check --ack <delivery_id> --wait')
|
||||
expect(squash(kernel)).toContain(
|
||||
'`worker-list --run <run_id> --terminal-state reclaimable --json`'
|
||||
)
|
||||
expect(squash(kernel)).toContain('do not follow it with `task-update --status completed`')
|
||||
})
|
||||
|
||||
it('treats long waits and release uncertainty as safe checkpoints', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
// Why: e92d7812d91 and c78f40fdd0b protect one rule; `## Outcome` states it once and each
|
||||
// gate cites it, so these pin the condition rather than a per-gate list of non-proofs.
|
||||
expect(squash(kernel)).toContain(
|
||||
'Only positive proof of exit authorizes stop, abandon, or retry, and only an accepted settlement authorizes release. Every other observation, absence included, is a checkpoint'
|
||||
)
|
||||
expect(squash(kernel)).toContain('A timeout or empty result is a checkpoint, not a failure')
|
||||
expect(squash(kernel)).toContain('Do not stop, retry, release, or launch a duplicate editor')
|
||||
expect(squash(kernel)).toContain('without the positive proof `## Outcome` requires')
|
||||
expect(squash(kernel)).toContain(
|
||||
'Only an accepted settlement authorizes it; no other observation does'
|
||||
)
|
||||
expect(kernel).toContain('never substitute `terminal close`')
|
||||
})
|
||||
|
||||
it('defines self-contained task specs and honest send attention semantics', () => {
|
||||
const kernel = readKernel()
|
||||
|
||||
for (const field of [
|
||||
'**Target:**',
|
||||
'**Change:**',
|
||||
'**Constraints:**',
|
||||
'**Ownership:**',
|
||||
'**Observable acceptance:**'
|
||||
]) {
|
||||
expect(fullHandoffs).toContain(supervisionPhrase)
|
||||
expect(kernel).toContain(field)
|
||||
}
|
||||
expect(kernel).toContain('successful `orchestration send` proves durable enqueue')
|
||||
expect(kernel).toContain('best-effort attention only')
|
||||
expect(squash(kernel)).toContain('does not prove the recipient read or accepted it')
|
||||
})
|
||||
})
|
||||
|
||||
describe('owned orchestration references', () => {
|
||||
it('routes every conditional read to exactly one shipped reference', () => {
|
||||
const kernel = readKernel()
|
||||
const routed = [...kernel.matchAll(/`references\/([^`]+\.md)`/gu)].map((match) => match[1])
|
||||
const shipped = readdirSync(referenceRoot)
|
||||
.filter((name) => name.endsWith('.md'))
|
||||
.sort()
|
||||
|
||||
const tableRoutes = [...kernel.matchAll(/^\|.*`references\/([^`]+\.md)`.*\|$/gmu)].map(
|
||||
(match) => match[1]
|
||||
)
|
||||
|
||||
expect([...new Set(routed)].sort()).toEqual(shipped)
|
||||
// Why the table and not every mention: prose may cite a reference the gate table already routes.
|
||||
expect(tableRoutes.sort()).toEqual(shipped)
|
||||
expect(kernel).toContain('ORCA skills get orchestration --full')
|
||||
// Why: the selector is the cheap path, so the kernel must teach it first and keep
|
||||
// `--full` only as the fallback for a CLI build that predates it.
|
||||
expect(squash(kernel)).toContain(
|
||||
'run `ORCA skills get orchestration --reference references/<file>.md`'
|
||||
)
|
||||
expect(squash(kernel)).toContain(
|
||||
'If the CLI rejects `--reference`, run `ORCA skills get orchestration --full`'
|
||||
)
|
||||
expect(squash(kernel)).toContain('If an older CLI rejects `--full`')
|
||||
})
|
||||
|
||||
it('documents custom model and effort handoffs without completion monitoring', () => {
|
||||
const skill = readSkill()
|
||||
const fullHandoffs = getSection(skill, 'Full Handoffs')
|
||||
it('owns expanded waves, launch preferences, reuse, and review boundaries', () => {
|
||||
const reference = readReference('coordinator-loop.md')
|
||||
|
||||
expect(fullHandoffs).toContain('Custom Codex model/effort handoff')
|
||||
expect(fullHandoffs).toContain(
|
||||
'does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments'
|
||||
)
|
||||
expect(fullHandoffs).toContain('codex --model gpt-5.5 -c model_reasoning_effort="xhigh"')
|
||||
expect(fullHandoffs).toContain(
|
||||
'Wait only for `tui-idle` when needed to avoid losing the prompt.'
|
||||
)
|
||||
expect(fullHandoffs).toContain('Do not monitor task completion.')
|
||||
})
|
||||
|
||||
it('clarifies sidebar lineage for same-worktree orchestrated workers', () => {
|
||||
const skill = readSkill()
|
||||
const workerTerminals = getSection(skill, 'Worker Terminals')
|
||||
|
||||
expect(workerTerminals).toContain(
|
||||
'Sidebar lineage and orchestration lifecycle are related but not identical.'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'A same-worktree worker may appear as a peer under that worktree in the sidebar'
|
||||
)
|
||||
expect(workerTerminals).toContain('while remaining a child dispatch in orchestration state')
|
||||
expect(workerTerminals).toContain(
|
||||
'only an actual child worktree creates visible parent/child worktree lineage'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.'
|
||||
)
|
||||
expect(workerTerminals).toContain(
|
||||
'When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree'
|
||||
)
|
||||
expect(workerTerminals).toContain('use `--no-parent` when it is not stacked')
|
||||
})
|
||||
|
||||
it('keeps review-only completions and named next-owner fixes in their lanes', () => {
|
||||
const skill = readSkill()
|
||||
|
||||
expect(skill).toContain(
|
||||
'A review-only `worker_done` reports findings; it does not authorize coordinator file edits.'
|
||||
)
|
||||
expect(skill).toContain('unless the user explicitly asked the coordinator to own fixes')
|
||||
expect(skill).toContain('dispatch or hand off fixes')
|
||||
expect(skill).toContain(
|
||||
"If the user's plan names a next owner agent " +
|
||||
'(for example, "then use opencode to create a PR")'
|
||||
)
|
||||
expect(skill).toContain('post-review corrections and PR prep belong to that named owner')
|
||||
expect(skill).toContain('the named owner edits files and creates the PR')
|
||||
})
|
||||
|
||||
it('keeps post-completion workers idle without subordinating the user', () => {
|
||||
const skill = readSkill()
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
|
||||
expect(agentGuidance).toContain('After sending `worker_done`, end that dispatched turn')
|
||||
expect(agentGuidance).toContain('idle at the agent prompt')
|
||||
expect(agentGuidance).toContain('Do not autonomously start more work, poll')
|
||||
expect(agentGuidance).toContain('A direct user instruction takes precedence')
|
||||
expect(agentGuidance).toContain('follow it without coordinator approval or a fresh Dispatch')
|
||||
expect(agentGuidance).toContain('never refuse it because of worker/coordinator roles')
|
||||
expect(agentGuidance).toContain("do not reuse the settled Dispatch's lifecycle IDs")
|
||||
expect(agentGuidance).toContain(
|
||||
'A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block'
|
||||
)
|
||||
expect(skill).not.toContain('post-completion polling messages')
|
||||
expect(skill).not.toContain('every 2 minutes')
|
||||
})
|
||||
|
||||
it('makes settled worker terminal release an explicit coordinator step', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
const nextAction = getSection(skill, 'Next Action')
|
||||
|
||||
expect(workerLoop).toContain(
|
||||
'# Process every message. For each accepted worker_done that is not immediately reused:\n' +
|
||||
'orca orchestration worker-release --dispatch <dispatch_id> --json'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Acknowledge only after every message and required release decision is handled'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca ' +
|
||||
'transfers cleanup ownership to the new Dispatch'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'Run `worker-release` after both succeeded and failed `worker_done` reports unless the user ' +
|
||||
'explicitly asked to keep that worker live.'
|
||||
)
|
||||
expect(workerLoop).toContain('Release is post-completion cleanup, not cancellation')
|
||||
expect(workerLoop).toContain('orca orchestration worker-retain --dispatch <dispatch_id> --json')
|
||||
expect(workerLoop).toContain(
|
||||
'the same Dispatch can be passed to `worker-release`, which clears the requested retention'
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'Coordinators must account for every settled worker terminal before waiting again or ending ' +
|
||||
'the turn'
|
||||
)
|
||||
expect(agentGuidance).toContain('released workers remain readable through `worker-read`')
|
||||
expect(nextAction).toContain(
|
||||
'After every accepted `worker_done`, either transfer the exact terminal to an immediate ' +
|
||||
'follow-up Dispatch or run `worker-release` before the next wait.'
|
||||
expect(reference).toContain('task-list --ready --brief --json')
|
||||
expect(reference).toContain('`--effort` requires `--model`')
|
||||
expect(reference).toContain('neither option combines with `--terminal`')
|
||||
expect(reference).toContain('`launch.requested` with `launch.effective`')
|
||||
expect(reference).toContain('worker-start --task <next_task_id> --terminal')
|
||||
expect(reference).toContain('A review-only `worker_done` authorizes synthesis')
|
||||
expect(squash(reference)).toContain(
|
||||
'post-review fixes and PR preparation remain with that owner'
|
||||
)
|
||||
})
|
||||
|
||||
it('documents per-invocation model and effort for supervised workers', () => {
|
||||
const workerLoop = getSection(readSkill(), 'Preferred Supervised Worker Loop')
|
||||
it('owns worker heartbeat, ask resume, escalation, failure, and idle', () => {
|
||||
const reference = readReference('worker-contract.md')
|
||||
|
||||
expect(workerLoop).toContain('opaque provider model id with `--model`')
|
||||
expect(workerLoop).toContain('`--effort` requires `--model`')
|
||||
expect(workerLoop).toContain('neither option can combine with `--terminal`')
|
||||
expect(workerLoop).toContain('--agent claude --model opus --effort high --json')
|
||||
expect(workerLoop).toContain('`launch.requested` and `launch.effective`')
|
||||
expect(reference).toContain('--type heartbeat')
|
||||
expect(reference).toContain('--task-id <task_id> --dispatch-id <dispatch_id>')
|
||||
expect(reference).toContain('--phase "<investigating|implementing|reviewing|waiting>"')
|
||||
expect(reference).toContain('--resume <message_id>')
|
||||
expect(reference).toContain('do not create a duplicate question')
|
||||
expect(reference).toContain('--type escalation')
|
||||
expect(reference).toContain('Send exactly one terminal report')
|
||||
expect(reference).toContain('Use `--outcome failed`')
|
||||
expect(reference).toContain('After `worker_done`, end the dispatched turn and idle')
|
||||
expect(squash(reference)).toContain(
|
||||
'ORCA orchestration check --terminal <worker_handle> --json'
|
||||
)
|
||||
expect(squash(reference)).toContain('once more immediately before `worker_done`')
|
||||
expect(squash(reference)).toContain(
|
||||
'`check` names its caller with `--terminal`, never `--from`'
|
||||
)
|
||||
expect(squash(reference)).toContain('If `check` returns `consumer_fenced`')
|
||||
expect(squash(reference)).toContain('An empty `check` never means you were replaced')
|
||||
})
|
||||
|
||||
it('never authorizes release from idle, timeout, or worker-side triggers', () => {
|
||||
const skill = readSkill()
|
||||
const workerLoop = getSection(skill, 'Preferred Supervised Worker Loop')
|
||||
const agentGuidance = getSection(skill, 'Agent Guidance')
|
||||
it('keeps heartbeat and worker_done recipes bound to the injected capability', () => {
|
||||
const reference = readReference('worker-contract.md')
|
||||
const recipes = [...reference.matchAll(/```text\n([\s\S]*?)```/gu)].map((match) => match[1])
|
||||
const heartbeat = recipes.find((recipe) => recipe.includes('--type heartbeat'))
|
||||
const workerDone = recipes.find((recipe) => recipe.includes('--type worker_done'))
|
||||
|
||||
// The prohibition sentence is the guard the negative patterns below rely on.
|
||||
expect(workerLoop).toContain(
|
||||
'Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, ' +
|
||||
'escalation, or rejected/stale `worker_done`.'
|
||||
)
|
||||
expect(workerLoop).toContain(
|
||||
'do not substitute `terminal close`; follow the exact recovery action in the receipt'
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/release[^.]*\bon (?:a |the )?(?:tui-?idle|idle|timeout|heartbeat|question|escalation)\b/iu
|
||||
)
|
||||
expect(skill).not.toMatch(
|
||||
/\b(?:after|on|upon) (?:a |the )?(?:tui-?idle|idle state|timeout|heartbeat)\b[^.]*\brelease/iu
|
||||
)
|
||||
expect(agentGuidance).toContain(
|
||||
'Do not autonomously start more work, poll, or attempt to close the terminal yourself'
|
||||
)
|
||||
expect(agentGuidance).not.toMatch(/worker-release[^.]*\byourself\b/iu)
|
||||
for (const recipe of [heartbeat, workerDone]) {
|
||||
expect(recipe).toContain('--from <worker_handle>')
|
||||
expect(recipe).toContain('--dispatch-capability <capability>')
|
||||
expect(recipe).toContain('--task-id <task_id> --dispatch-id <dispatch_id>')
|
||||
}
|
||||
expect(workerDone).not.toContain('--files-modified')
|
||||
expect(workerDone).not.toContain('--report-path')
|
||||
expect(squash(reference)).toContain('only when applicable, using actual paths')
|
||||
expect(reference).toContain('Do not send documentation placeholders as metadata')
|
||||
})
|
||||
|
||||
it('documents @grok in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
it('owns local, folder, worktree, SSH, WSL, remote, and mixed-version placement', () => {
|
||||
const reference = readReference('placement-and-remote.md')
|
||||
|
||||
expect(messaging).toContain('`@grok`')
|
||||
expect(reference).toContain('--worktree current --agent codex')
|
||||
expect(squash(reference)).toContain(
|
||||
'A worktree selector needs the full `<repo-id>::<path>` value Orca returned, passed as `id:<newFullWorktreeId>`; a bare repo id is not a worktree id'
|
||||
)
|
||||
expect(reference).toContain('--worktree new-child')
|
||||
expect(reference).toContain('--worktree new-top-level')
|
||||
expect(reference).toContain('Folder workspaces are first-class')
|
||||
expect(reference).toContain('Remote `current` and `new-child` are invalid')
|
||||
expect(squash(reference)).toContain("`--on` selects only the worker's execution server")
|
||||
expect(squash(reference)).toContain(
|
||||
'route every follow-up, read, stop, and cleanup by Dispatch ID'
|
||||
)
|
||||
expect(reference).toContain('`live`, `unverifiable`, or `exited`')
|
||||
expect(squash(reference)).toContain('unknown stream opcodes can be silently dropped')
|
||||
expect(reference).toContain('printed `orca-ide`')
|
||||
expect(squash(reference)).toContain(
|
||||
'ORCA project setup-existing-folder --project <project_id> --host <host_id> --path <abs_path> --kind folder --json'
|
||||
)
|
||||
expect(squash(reference)).toContain('and rejects a plain directory')
|
||||
expect(reference).toContain(
|
||||
'ORCA orchestration worker-list --run <run_id> --include-remote --json'
|
||||
)
|
||||
expect(squash(reference)).toContain(
|
||||
'enumerate remote workers with `--include-remote` or every one of them reads `unverifiable`'
|
||||
)
|
||||
})
|
||||
|
||||
it('documents @cursor in the Messaging group address list', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
it('owns FIFO mail, Dispatch addresses, groups, questions, and gates', () => {
|
||||
const reference = readReference('messaging-and-gates.md')
|
||||
|
||||
expect(messaging).toContain('`@cursor`')
|
||||
expect(reference).toContain('oldest FIFO Delivery')
|
||||
expect(squash(reference)).toContain('Process every row')
|
||||
expect(squash(reference)).toContain(
|
||||
'A Delivery therefore always carries the whole FIFO batch whatever its types, and a `check` without `--wait` hands that batch over unfiltered'
|
||||
)
|
||||
expect(reference).toContain('send --to dispatch:<dispatch_id>')
|
||||
for (const group of ['@all', '@grok', '@cursor', '@worktree:<id>']) {
|
||||
expect(reference).toContain(group)
|
||||
}
|
||||
expect(reference).toContain('Dispatch lifecycle messages never target groups')
|
||||
expect(reference).toContain('gate-create --task <task_id>')
|
||||
expect(reference).toContain("Do not create a gate merely to answer a worker's `ask`")
|
||||
expect(reference).toContain('successful `send` proves durable enqueue')
|
||||
expect(squash(reference)).toContain('Wake and nudge are best-effort attention only')
|
||||
expect(squash(reference)).toContain(
|
||||
'`check` names its caller with `--terminal <handle>` and is the only verb that rejects `--from`'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => {
|
||||
const skill = readSkill()
|
||||
const messaging = getSection(skill, 'Messaging')
|
||||
const workerTerminals = getSection(skill, 'Worker Terminals')
|
||||
const agentFirstExample = workerTerminals.match(
|
||||
/```bash\norca worktree create --name <task-name> --agent codex --setup run --json\n[\s\S]*?```/
|
||||
)?.[0]
|
||||
it('owns positive-evidence retry, unknown outcomes, retain/release, and no terminal close', () => {
|
||||
const reference = readReference('recovery-and-cleanup.md')
|
||||
|
||||
expect(workerTerminals).toContain('For an allowed new worktree, use agent-first:')
|
||||
expect(workerTerminals).toContain('fallback shell + agent pair')
|
||||
expect(workerTerminals).toContain(
|
||||
'repo setup and default-terminal settings may add intentional tabs or splits'
|
||||
expect(squash(reference)).toContain('| `ready` or active | Keep waiting')
|
||||
expect(squash(reference)).toContain('| `outcome_unknown` | Inspect')
|
||||
expect(squash(reference)).toContain('| Remote contact lost | Preserve `unverifiable`')
|
||||
expect(reference).toContain('--retry-of <dispatch_id>')
|
||||
expect(squash(reference)).toContain('Placement is never silently inherited')
|
||||
expect(reference).toContain('worker-abandon --dispatch')
|
||||
expect(reference).toContain('worker-retain --dispatch')
|
||||
expect(reference).toContain('worker-release --dispatch')
|
||||
expect(squash(reference)).toContain('`release_pending` or `release_unknown`')
|
||||
expect(squash(reference)).toContain('Never substitute `terminal close`')
|
||||
})
|
||||
|
||||
it('owns the lost-response question and the request-show verdicts', () => {
|
||||
const reference = squash(readReference('recovery-and-cleanup.md'))
|
||||
|
||||
expect(reference).toContain('request-show --request <request_id> --json')
|
||||
expect(reference).toContain('--retry-request <request_id>')
|
||||
expect(reference).toContain('`completed` means the mutation already took effect')
|
||||
expect(reference).toContain('`pending` means the original mutation is still running')
|
||||
expect(reference).toContain('that is not proof nothing happened')
|
||||
expect(reference).toContain('terminal send --wait-submit <seconds>')
|
||||
})
|
||||
|
||||
it('names worker-list as the enumerating command and the agent-liveness authority', () => {
|
||||
const reference = squash(readReference('recovery-and-cleanup.md'))
|
||||
|
||||
expect(reference).toContain('ORCA orchestration worker-list --run <run_id> --json')
|
||||
expect(reference).toContain("`worker-show`'s `observation.status` is PTY liveness only")
|
||||
expect(reference).toContain(
|
||||
'`projection.attention.categories`, `projection.attention.requiresAction`'
|
||||
)
|
||||
expect(workerTerminals).toContain('without configured default tabs')
|
||||
expect(workerTerminals).toContain(
|
||||
'only after `terminal list` or `terminal show` confirms it is an unused shell'
|
||||
expect(reference).toContain('`projection.nextAction` argv')
|
||||
expect(reference).toContain('the fleet verdict decides')
|
||||
expect(reference).toContain(
|
||||
'ORCA orchestration worker-list --run <run_id> --include-remote --json'
|
||||
)
|
||||
expect(reference).toContain('reads `unverifiable` until you enumerate with `--include-remote`')
|
||||
expect(reference).toContain('follow `page.nextCursor` with `--cursor <value>`')
|
||||
})
|
||||
|
||||
it('requires positive evidence of exit before stop, abandon, retry, or release', () => {
|
||||
const reference = squash(readReference('recovery-and-cleanup.md'))
|
||||
|
||||
expect(reference).toContain('Leave the wait only on positive proof the agent stopped')
|
||||
expect(reference).toContain('`unverifiable` is always absence')
|
||||
expect(reference).toContain('Absence never authorizes stop, abandon, retry, or release')
|
||||
expect(reference).toContain(
|
||||
'| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |'
|
||||
)
|
||||
})
|
||||
|
||||
it('owns the custom topology exception without claiming process ownership', () => {
|
||||
const reference = readReference('low-level-topology.md')
|
||||
|
||||
expect(reference).toContain('only when `worker-start` cannot express')
|
||||
expect(reference).toContain('terminal create --worktree active')
|
||||
expect(reference).toContain('dispatch --task <task_id> --to <handle> --inject')
|
||||
expect(reference).toContain('operator-created process unsupervised')
|
||||
expect(squash(reference)).toContain('creates no supervised worker resource row')
|
||||
expect(reference).toContain('Use `worker-start --terminal <handle>`')
|
||||
expect(squash(reference)).toContain('never use it for an ownership handoff')
|
||||
})
|
||||
|
||||
it('owns legacy labels, read-only degradation, exact recovery, and takeover', () => {
|
||||
const reference = readReference('legacy-contract-migration.md')
|
||||
|
||||
expect(reference).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(reference).toContain('[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]')
|
||||
expect(reference).toContain('[LEGACY READ-ONLY]')
|
||||
expect(squash(reference)).toContain(
|
||||
'degrade to read-only inspection and never fall back to local execution'
|
||||
)
|
||||
expect(squash(reference)).toContain(
|
||||
'must not spawn, write, signal, stop, switch, focus, split, or inject'
|
||||
)
|
||||
expect(reference).toContain('launcher status `75`')
|
||||
expect(reference).toContain('run_legacy_local')
|
||||
expect(reference).toContain('Recovered orchestration work from a contract update')
|
||||
expect(reference).toContain('run-use --id <adopted_run_id> --takeover-legacy')
|
||||
expect(reference).toContain(
|
||||
'Never take over while the original coordinator is actively coordinating'
|
||||
)
|
||||
expect(workerTerminals).not.toContain('bare create opens a default shell')
|
||||
expect(workerTerminals).not.toContain('ends with **one** agent tab')
|
||||
expect(agentFirstExample).toBeDefined()
|
||||
expect(agentFirstExample).not.toContain('orca terminal list')
|
||||
expect(agentFirstExample).toContain('agentTerminalHandle')
|
||||
expect(agentFirstExample).toContain('startupTerminal.handle')
|
||||
expect(messaging).toContain('Prefer `agentTerminalHandle` from the create response')
|
||||
expect(messaging).toContain('Continue with the replacement handle only')
|
||||
expect(messaging).toContain('never writes to terminal input or remotely wakes another terminal')
|
||||
expect(messaging).toContain('Use `orchestration dispatch --inject` to deliver a tracked task')
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration install stub', () => {
|
||||
it('points at the version-matched guide and preserves the safe resolver', () => {
|
||||
it('preserves the safe version-matched resolver and bounded old-binary fallback', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8')
|
||||
|
||||
expect(stub).toContain('discovery stub')
|
||||
expect(stub).toContain('ORCA skills get orchestration')
|
||||
// The safe CLI-resolution contract must survive in the stub, never a bare `orca`.
|
||||
expect(stub).toContain('ORCA_CLI_COMMAND')
|
||||
expect(stub).toContain('orca-dev')
|
||||
expect(stub).toContain('orca-ide')
|
||||
expect(stub).toContain('GNOME Orca screen reader')
|
||||
expect(squash(stub)).toContain('explicitly reports that `skills get` is an unknown command')
|
||||
expect(stub).toContain('do not invent commands')
|
||||
expect(stub).not.toMatch(/^orca /mu)
|
||||
})
|
||||
|
||||
it('does not tell agents to mutate orchestration state before loading the guide', () => {
|
||||
const preGuide = readFileSync(stubPath, 'utf8').split('## Load the full guide')[0]
|
||||
|
||||
expect(preGuide).not.toContain('orca orchestration task-create')
|
||||
expect(preGuide).not.toContain('orca orchestration dispatch')
|
||||
})
|
||||
|
||||
it('gives older binaries a bounded fallback instead of a dead end', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8').replace(/\s+/gu, ' ')
|
||||
|
||||
expect(stub).toContain('explicitly reports that `skills get` is an unknown command')
|
||||
expect(stub).toContain('do not invent commands')
|
||||
expect(stub).toContain('ask the user rather than guessing')
|
||||
})
|
||||
|
||||
it('drops the changing command reference from the installable file', () => {
|
||||
it('performs no orchestration mutation before loading the guide', () => {
|
||||
const stub = readFileSync(stubPath, 'utf8')
|
||||
const preGuide = stub.split('## Load the full guide')[0]
|
||||
|
||||
// Version-sensitive command detail lives in the binary-served guide now, not here.
|
||||
expect(stub).not.toContain('check --wait')
|
||||
expect(stub).not.toContain('dispatch-show')
|
||||
expect(stub.length).toBeLessThan(readFileSync(guidePath, 'utf8').length)
|
||||
})
|
||||
|
||||
it('keeps the routing frontmatter identical to the guide', () => {
|
||||
const frontmatter = (text) => /^---\n[\s\S]*?\n---\n/u.exec(text)[0]
|
||||
|
||||
expect(frontmatter(readFileSync(stubPath, 'utf8'))).toBe(
|
||||
frontmatter(readFileSync(guidePath, 'utf8'))
|
||||
)
|
||||
expect(preGuide).not.toContain('orchestration task-create')
|
||||
expect(preGuide).not.toContain('orchestration dispatch')
|
||||
expect(frontmatter(stub)).toBe(frontmatter(readKernel()))
|
||||
expect(stub.length).toBeLessThan(readKernel().length)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -145,7 +145,7 @@ orca orchestration ask \
|
||||
--json
|
||||
```
|
||||
|
||||
With `--json`, `ask` prints a single JSON object so workers can pipe it to `jq -r .answer`.
|
||||
With `--json`, `ask` prints the standard `{id, ok, result, _meta}` envelope, so workers read the answer with `jq -r .result.answer`.
|
||||
|
||||
## Decision gates
|
||||
|
||||
|
||||
@@ -290,6 +290,8 @@ List bundled guides, print a version-matched guide, or install/update hybrid ski
|
||||
```bash
|
||||
orca skills list
|
||||
orca skills get orca-cli
|
||||
orca skills get orchestration --references
|
||||
orca skills get orchestration --reference recovery-and-cleanup
|
||||
orca skills get orchestration --full
|
||||
orca skills install --skill orca-cli --skill orchestration
|
||||
orca skills install --all --dry-run
|
||||
|
||||
@@ -39,10 +39,14 @@ After `npx skills add`, agents see a short stub that says:
|
||||
```bash
|
||||
orca skills list
|
||||
orca skills get orca-cli
|
||||
orca skills get orchestration --references
|
||||
orca skills get orchestration --reference recovery-and-cleanup
|
||||
orca skills get orchestration --full
|
||||
orca skills get orca-linear --json
|
||||
```
|
||||
|
||||
A guide's action gates name conditional references. `--reference <name>` prints one of them alone, so an agent pays for the kernel plus that document instead of the whole package; `--references` lists the names. The name may be bare (`recovery-and-cleanup`) or spelled as the guide writes it (`references/recovery-and-cleanup.md`). `--full` still prints the kernel plus every reference.
|
||||
|
||||
Add `--json` when an agent needs deterministic output for automation. `skills show` is an alias for `skills get`.
|
||||
|
||||
## Keep skills up to date
|
||||
|
||||
@@ -131,17 +131,17 @@
|
||||
"name": "orchestration",
|
||||
"sourcePath": "skills/orchestration",
|
||||
"releaseRevision": 29,
|
||||
"packageDigest": "689e31d84256aded123c801eaa87413474943a9a30d96bff9a19d0a321aefb54",
|
||||
"gitTreeSha": "902cc33dd65730b32ac234dd0ae7166d75498b46",
|
||||
"packageDigest": "894d6f421cb96c2777e73055df867e2fdfca8dd05f0340d50a93cb33a8e85e3a",
|
||||
"gitTreeSha": "da5b5c3f78634bbe12922e526ea227509faa9de0",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4398,
|
||||
"size": 4539,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
|
||||
"textNormalizedSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
|
||||
"identitySha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18"
|
||||
"exactSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954",
|
||||
"textNormalizedSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954",
|
||||
"identitySha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1046,17 +1046,17 @@
|
||||
},
|
||||
{
|
||||
"releaseRevision": 29,
|
||||
"packageDigest": "689e31d84256aded123c801eaa87413474943a9a30d96bff9a19d0a321aefb54",
|
||||
"gitTreeSha": "902cc33dd65730b32ac234dd0ae7166d75498b46",
|
||||
"packageDigest": "894d6f421cb96c2777e73055df867e2fdfca8dd05f0340d50a93cb33a8e85e3a",
|
||||
"gitTreeSha": "da5b5c3f78634bbe12922e526ea227509faa9de0",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"size": 4398,
|
||||
"size": 4539,
|
||||
"executable": false,
|
||||
"classification": "text",
|
||||
"exactSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
|
||||
"textNormalizedSha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18",
|
||||
"identitySha256": "19ffdc1fe0d2c97dae845e8d636edb16781453ce2ec26f65a323c492ef90da18"
|
||||
"exactSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954",
|
||||
"textNormalizedSha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954",
|
||||
"identitySha256": "937237cbb3449ff88f67efbcec0b6c6d64a23dbfb1b28c88260e4d0094f50954"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -181,6 +181,7 @@ ORCA terminal read --terminal <handle> --json
|
||||
ORCA terminal read --terminal <handle> --cursor <cursor> --limit 1000 --json
|
||||
ORCA terminal read --json
|
||||
ORCA terminal send --terminal <handle> --text "continue" --enter --json
|
||||
ORCA terminal send --terminal <handle> --text "continue" --enter --wait-submit 10 --json
|
||||
ORCA terminal send --text "echo hello" --enter --json
|
||||
ORCA terminal wait --terminal <handle> --for exit --timeout-ms 5000 --json
|
||||
ORCA terminal wait --terminal <handle> --for tui-idle --timeout-ms 300000 --json
|
||||
@@ -204,7 +205,11 @@ Terminal rules:
|
||||
- `terminal list --json` omits `visualLayouts` to keep the common agent payload bounded. Add `--include-visual-layouts` only when tab and pane topology is required.
|
||||
- Use `terminal read` before `terminal send` unless the next input is obvious.
|
||||
- Use `terminal send` only for direct terminal input or one-off prompts where no task state, inbox, or reply tracking is needed.
|
||||
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --unread --format` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.
|
||||
- A text-plus-Enter agent prompt returns a durable request ID and additive stages: `input_accepted`, then `turn_started` once the agent's turn is proven. Raw text-only, bare Enter, interrupt, and terminal query replies keep their existing direct-input behavior.
|
||||
- A default send observes for 0 seconds, so a receipt that stops at `input_accepted` is expected and its warning means "unproven", not "failed". Pass `--wait-submit` when you need proof of submission.
|
||||
- `--wait-submit <seconds>` only observes the same accepted prompt. A timeout returns queued/input-accepted truth without resending; after an ambiguous transport failure, repeat the exact command with the reported `--retry-request <id>`. Both text and `--json` receipts carry the same `warnings`.
|
||||
- An older host reports a legacy `old-host` fallback for an ordinary send and refuses `--wait-submit` or `--retry-request` before input, because it cannot provide durable replay.
|
||||
- For structured coordination, invoke the `orchestration` skill; it uses `orca orchestration ...` commands for messages, handoffs, task DAGs, dispatches, inbox/reply flows, and coordinator loops. A receiving agent can run `orca orchestration check --peek --format --json` to render its unread mail in agent-readable form; this checks the caller's inbox and does not remotely deliver input to another terminal.
|
||||
- Use `terminal create --worktree active --command "<agent>"` for a fresh agent in the current worktree. Use `worktree create --agent <agent>` only for a separate checkout (agent in the first terminal — do not also `terminal create` the same agent).
|
||||
- Use `terminal wait --for tui-idle` for agent CLIs such as Claude Code, Gemini, Codex, OMP, Pi, and Grok; always pass `--timeout-ms`.
|
||||
- Terminal handles are runtime-scoped. Use `startupTerminal.handle` as the sole agent handle when `worktree create --agent` returns it; if Orca restarts, omits the handle, or returns `terminal_handle_stale`, reacquire with `terminal list` and continue with the replacement only.
|
||||
|
||||
+182
-430
@@ -1,449 +1,201 @@
|
||||
---
|
||||
name: orchestration
|
||||
description: >-
|
||||
Use Orca orchestration for structured multi-agent coordination: threaded
|
||||
messages, blocking ask/reply flows, task dispatch, worker_done/escalation
|
||||
waits, task DAGs, decision gates, or coordinator loops. Use `orca-cli`
|
||||
instead for full ownership handoffs, including requests phrased as "hand
|
||||
off", "handoff", "handover", "give this to another agent", or "another
|
||||
worktree" when the user did not explicitly ask to supervise, monitor, wait
|
||||
for results, or coordinate a DAG. Use `orca-cli` for terminal control,
|
||||
lightweight terminal prompts, shell commands, Orca worktree management,
|
||||
reading or waiting on terminals, and the Orca embedded browser. Use Computer
|
||||
Use for external browser windows, webviews, Orca app UI, or desktop UI
|
||||
outside Orca's embedded browser only when the task requires OS/window-level
|
||||
control such as focus, menus, dialogs, coordinates, or screenshots. Use
|
||||
`orca-cli` for Orca's embedded pages and a page-automation tool such as
|
||||
Playwright or CDP for external pages.
|
||||
Coordinate supervised Orca workers: threaded messages, blocking ask/reply,
|
||||
task dispatch, worker_done/escalation waits, task DAGs, decision gates,
|
||||
coordinator loops, and decomposing work across agents. Use `orca-cli` for full
|
||||
ownership handoffs — "hand off", "handoff", "handover", "give this to another
|
||||
agent", "another worktree" — unless asked to supervise, monitor, or coordinate
|
||||
a DAG, and for terminal control, lightweight terminal prompts, shell commands,
|
||||
Orca worktree management, and reading or waiting on terminals. Use Computer
|
||||
Use for external browser windows, webviews, Orca app UI, or desktop UI outside
|
||||
Orca's embedded browser only when the task requires OS/window-level control
|
||||
such as focus, menus, dialogs, coordinates, or screenshots. Use `orca-cli` for
|
||||
Orca's embedded pages and a page-automation tool such as Playwright or CDP for
|
||||
external pages.
|
||||
---
|
||||
|
||||
# Orca Inter-Agent Orchestration
|
||||
# Orca orchestration
|
||||
|
||||
Orchestration is Orca's structured coordination layer for agent messages, task ownership, dispatch state, and worker completion tracking.
|
||||
Orchestration is Orca's structured coordination layer. It records who owns work,
|
||||
which attempt is authoritative, and when supervised work has settled.
|
||||
|
||||
Use this skill when coordination state matters. For lightweight terminal prompts or basic worktree/terminal/built-in-browser control, use `orca-cli`.
|
||||
## Outcome
|
||||
|
||||
## Tool Boundary
|
||||
**Result:** every in-scope Task has one explicit outcome and every settled worker
|
||||
terminal has a next owner or cleanup decision. **Next consumer:** the user who
|
||||
requested supervision. **Done:** all expected Dispatches have settled, every
|
||||
delivered message was processed before acknowledgment, each settled worker was
|
||||
reused, explicitly retained, or released, and the turn ends only when the report
|
||||
to that user names, per Task, its outcome, the evidence behind it, and any
|
||||
unresolved blocker.
|
||||
|
||||
If a task says to use Orca orchestration, the coordinator must create or bind a Run, create the Task with `orca orchestration task-create`, then attach the worker with either the preferred `orca orchestration worker-start` composition or the low-level `orca orchestration dispatch --inject` path.
|
||||
**Safe failure:** preserve work and authority and report the state as unknown or
|
||||
`unverifiable`. Only positive proof of exit authorizes stop, abandon, or retry,
|
||||
and only an accepted settlement authorizes release. Every other observation,
|
||||
absence included, is a checkpoint.
|
||||
|
||||
Do not substitute non-Orca subagent tools, generic agent-spawn APIs, or chat-only parallel worker features. Those may create useful workers, but they do not create Orca task/dispatch provenance, injected lifecycle preambles, `worker_done` authority, or decision gates.
|
||||
## Classify the role
|
||||
|
||||
Before claiming a worker was orchestrated, verify the task/dispatch exists:
|
||||
| Current context | Role | Route |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------ |
|
||||
| The user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use a decision gate, or manage ask/reply | Coordinator | Use the supervised loop below |
|
||||
| The current prompt contains a live injected preamble with Task and Dispatch IDs | Dispatched worker | Follow the preamble and the worker obligations below |
|
||||
| The user asks to hand off ownership or start another agent/worktree without supervision | Handoff owner | Use `orca-cli`; create no Run, Task, or Dispatch and do not monitor completion |
|
||||
| A message carries a legacy authority label | Compatibility operator | Load the legacy contract reference before any lifecycle mutation |
|
||||
| No live preamble and no explicit supervision | Ordinary terminal agent | Do not emit lifecycle messages; use `orca-cli` for terminal/worktree work |
|
||||
|
||||
```bash
|
||||
orca orchestration task-list --json
|
||||
orca orchestration dispatch-show --task <task_id> --json
|
||||
Model or effort selection does not make a handoff supervised. Never substitute a
|
||||
non-Orca subagent tool when Orca orchestration provenance was requested.
|
||||
|
||||
## Authority and safety floor
|
||||
|
||||
- A Run is a durable namespace and coordinator inbox; it does not schedule or
|
||||
place workers. A Task is work. A Dispatch is one authoritative Task attempt.
|
||||
- Lifecycle authority comes from the active Dispatch, not a terminal title,
|
||||
copied ID, old database row, provider transcript, or visible pane.
|
||||
- Workers use the exact executable, handle, capability, Task ID, and Dispatch ID
|
||||
in the live preamble. Never reconstruct, translate, or broaden those arguments.
|
||||
- After remote start, address the worker by Dispatch ID. The execution host owns
|
||||
process, filesystem, transcript, stop, and cleanup facts. Preserve the verdicts
|
||||
`live` / `unverifiable` / `exited`; contact loss is not process death.
|
||||
- Liveness is layered: `worker-list`'s `projection.liveness` is the fleet verdict
|
||||
for the agent; `worker-show`'s `observation.status` is PTY liveness only. A live
|
||||
terminal can still hold a dead or stuck agent.
|
||||
- Folder workspaces are valid; never require Git or assume a worktree.
|
||||
- Clients and remote servers update independently. Treat unknown optional fields
|
||||
as absent. A new stream operation requires advertised capability because old
|
||||
decoders may silently drop unknown opcodes. Never fall back to local execution
|
||||
when remote authority or capability is unproven.
|
||||
- Use the executable you used to run `skills get` for the entire run. In the
|
||||
examples below, replace `ORCA` with it; do not create a shell variable or run
|
||||
`ORCA` literally. If it fails, report that exact error instead of switching.
|
||||
- A successful `orchestration send` proves durable enqueue; its wake or nudge is
|
||||
best-effort attention only and does not prove the recipient read or accepted it.
|
||||
|
||||
## Worker obligations
|
||||
|
||||
The injected preamble is authoritative. A dispatched worker must:
|
||||
|
||||
1. Do only the current Task and use the preamble's `ask` command for a blocking
|
||||
coordinator question. Never open a local question TUI the coordinator cannot
|
||||
answer. Resume the same message ID after an ask timeout.
|
||||
2. Send heartbeats only at the cadence in the preamble. A heartbeat proves
|
||||
liveness, not completion.
|
||||
3. Read coordinator follow-ups at each natural checkpoint — before starting a
|
||||
new file, after a test run — and once more immediately before `worker_done`:
|
||||
`ORCA orchestration check --terminal <your_handle> --json`.
|
||||
4. Send `worker_done` exactly once, from the dispatched terminal, with a
|
||||
three-sentence executive summary, both lifecycle IDs, and explicit
|
||||
`--outcome succeeded` or `--outcome failed`. Never encode failure only in prose.
|
||||
5. Append `--files-modified` and `--report-path` only with real values when
|
||||
applicable. After `worker_done`, end the dispatched turn and idle; do not poll
|
||||
or start new work.
|
||||
|
||||
A direct user instruction after completion starts new user-owned work and takes
|
||||
precedence over the idle rule. Do not reuse the settled lifecycle IDs.
|
||||
|
||||
## Canonical supervised loop
|
||||
|
||||
Confirm the runtime, bind one Run, and start the full independent wave before
|
||||
waiting. `worker-start --spec` creates the Task and its attempt in one call:
|
||||
|
||||
```text
|
||||
ORCA status --json
|
||||
ORCA orchestration run-create --objective "<objective>" --json
|
||||
ORCA orchestration worker-start --spec "<worker A task>" --worktree current --agent codex --json
|
||||
ORCA orchestration worker-start --spec "<worker B task>" --worktree current --agent claude --model sonnet --json
|
||||
ORCA orchestration check --wait --types "worker_done,escalation,question" --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
If the work was accidentally run outside Orca orchestration, say so plainly. To repair provenance, rerun or revalidate the needed work through a fresh Orca terminal plus injected dispatch; do not retroactively describe the external worker as orchestrated.
|
||||
If `worker-start` exits non-zero, do not relaunch. Read the receipt's
|
||||
`failedStage` and `residualResources`, then load
|
||||
`references/recovery-and-cleanup.md`.
|
||||
|
||||
## When To Use
|
||||
Use `task-create` plus `worker-start --task <task_id>` for planned fan-out with
|
||||
dependencies or a retry of a known Task. Use dependencies only for real ordering
|
||||
and prefer parallel waves over chains deeper than three or four steps; nested
|
||||
workers obey the depth limit, and a new Run does not reset the caller's depth.
|
||||
|
||||
- Send/reply/ask between agent terminals with persistent messages.
|
||||
- Dispatch structured tasks to workers and wait for `worker_done` or `escalation`.
|
||||
- Track task DAGs with dependencies.
|
||||
- Run coordinator loops or decision gates.
|
||||
A consuming `check` names its caller with `--terminal <handle>`, never `--from`;
|
||||
omit it inside the coordinator's own Orca terminal. It returns the bound Run's
|
||||
oldest FIFO Delivery and replays that batch until acknowledged. Process every
|
||||
message: reply to questions, validate each `worker_done` against the expected
|
||||
active Dispatch, and decide each settled terminal's next owner before the ack:
|
||||
|
||||
Do not use orchestration merely because the user says "hand off", "handoff", "handover", "give this to another agent", or asks for another worktree/agent/model/effort. Those are full ownership transfers unless the user explicitly asks to supervise, monitor, wait for worker completion/results, coordinate a DAG, use decision gates, or keep a blocking ask/reply loop.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- `orca status --json` should show a running runtime.
|
||||
- `orca` must be on PATH (`orca-ide` on Linux).
|
||||
- The orchestration experimental feature must be enabled in Settings > Experimental.
|
||||
- `orca orchestration` commands are RPC calls to the running Orca runtime.
|
||||
|
||||
## Contract Migration
|
||||
|
||||
Orca adopts a live pre-update orchestration assignment into an ordinary Run. Adoption preserves the existing agent process, PTY/session, terminal handle, tab/leaf/pane, worktree or folder workspace, Task, and Dispatch; it never restarts or replaces the worker. The retired scheduler is not revived, and a newly created attempt uses the current grammar.
|
||||
|
||||
Treat the authority label on injected or formatted messages as definitive:
|
||||
|
||||
- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported command printed with the message, using the same CLI executable and arguments that the original prompt supplied.
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded, at-least-once cutover replay. Process it idempotently and acknowledge it only through the exact displayed guidance.
|
||||
- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or lifecycle action.
|
||||
- An unlabeled current message uses the current guide and current grammar.
|
||||
|
||||
An explicitly selected current Run, attested current Run binding, current Dispatch, or federated attachment takes precedence over legacy fallback. A retained adoption record alone never turns a current command into a legacy call.
|
||||
|
||||
Database provenance, an old-looking terminal, or a legacy Run ID does not prove mutation authority. If the runtime cannot prove liveness, principal ownership, capability, or the exact legacy contract, it degrades to read-only inspection and must not fall back to local execution. Exact recovery may restore the already-live PTY once in its original inactive background tab. It must not spawn, write, signal, stop, switch, focus, split, or inject a terminal. Loss of lifecycle authority does not invalidate the existing assignment, process, or filesystem work.
|
||||
|
||||
Compatibility retries have narrow guarantees. A pending ask, a reply, a final Dispatch settlement, and a consuming check have durable recovery identities. A-era heartbeat and escalation calls remain at-least-once across a manual A-to-B retry because identical later signals may be intentional. If an A-era ask may already have been answered, run the exact non-consuming recovery check printed by the runtime first; after its answer is printed and acknowledged, a new invocation with the same question creates a new question. Never guess among multiple identical question threads.
|
||||
|
||||
When a compatibility or recovery command returns structured next-step arguments, run those exact arguments with the same CLI executable. The arguments intentionally omit the executable name so the guidance works with `orca`, `orca-ide`, `orca-dev`, or another configured Orca CLI command. Do not translate the command from memory, broaden its recipient, or retry it as a current mutation unless the returned guidance explicitly says to.
|
||||
|
||||
On packaged Windows, a legacy ask uses a two-step commit/resume protocol. The initial command durably commits the question, prints its exact `ask --resume <message_id>` command, and exits with launcher status `75`; it does not wait for the answer. Run that exact resume command after the launcher or update boundary. Resume is idempotent and read-oriented: it waits for the already-committed question and does not create another one. For a WSL process that received compatibility proof at launch, use the printed executable `orca-ide` WSL resume command so the same distro and packaged launcher authority are preserved; do not substitute a PATH-resolved local CLI. Older WSL processes that never received the hidden launch token remain lifecycle read-only after the update, even while their terminal and filesystem work continue.
|
||||
|
||||
Legacy inspection remains available without consuming mail:
|
||||
|
||||
```bash
|
||||
orca orchestration run-list --json
|
||||
# run_legacy_local is an empty audit tombstone after adoption.
|
||||
orca orchestration run-show --id run_legacy_local --json
|
||||
# In run-list, find the ordinary Run whose objective is:
|
||||
# "Recovered orchestration work from a contract update"
|
||||
orca orchestration run-show --id <adopted_run_id> --json
|
||||
orca orchestration task-list --run <adopted_run_id> --json
|
||||
orca orchestration inbox --full --json
|
||||
orca orchestration check --terminal <legacy_handle> --peek --format --json
|
||||
orca terminal read --terminal <legacy_handle> --json
|
||||
orca terminal wait --terminal <legacy_handle> --for tui-idle --timeout-ms 60000 --json
|
||||
```text
|
||||
ORCA orchestration reply --id <message_id> --body "<answer>" --json
|
||||
ORCA orchestration worker-release --dispatch <dispatch_id> --json
|
||||
ORCA orchestration check --ack <delivery_id> --wait --types "worker_done,escalation,question" --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
If the original coordinator is unavailable or cannot prove its retained authority, a current coordinator may explicitly take over the adopted Run from its own live agent terminal:
|
||||
|
||||
```bash
|
||||
orca orchestration run-use --id <adopted_run_id> --takeover-legacy --json
|
||||
orca orchestration check --run <adopted_run_id> --json
|
||||
```
|
||||
|
||||
Takeover fences only the old coordinator, binds the current one, and moves pending worker mail into current Run Delivery. It is bound to the authenticated invoking terminal; `--from` cannot name another coordinator. Live legacy workers keep their original Tasks, Dispatches, processes, filesystems, and old prompt commands; their later questions, escalations, and completion reports route to the current coordinator. Do not use takeover while the original coordinator is still actively coordinating, because its later lifecycle mutations are rejected.
|
||||
|
||||
Do not launch a replacement editor merely because the desktop app or runtime was updated. If adoption cannot prove continuing authority, keep the original worker as the only editor until it reaches a stable handoff point, then use a new current Dispatch in a conflict-free placement for any remaining work.
|
||||
|
||||
## Ownership
|
||||
|
||||
New orchestration messages and tasks belong to one explicitly bound Run. A Run is only a durable namespace and coordinator inbox; it never schedules or places workers. Lifecycle authority comes from the active Dispatch, and terminal handles remain routing metadata rather than durable identity. Send `worker_done` and `heartbeat` from the worker's own terminal; Orca routes them to that Dispatch's Run.
|
||||
|
||||
Classify inherited context before sending lifecycle messages:
|
||||
|
||||
- Coordinated subtask: a live coordinator owns the DAG and waits on this dispatch. Follow the preamble exactly, including `worker_done`, heartbeat/status, `ask`, and `escalation`.
|
||||
- Full handoff means ownership transfer, not supervised dispatch. The original actor is not monitoring a DAG, so do not create lifecycle obligations unless the user explicitly asks you to supervise.
|
||||
- Classify requests containing "hand off", "handoff", "handover", "give this to another agent", "give this to another worktree", "another agent", or "another worktree" as full handoffs by default, even when the user names a custom model or reasoning effort.
|
||||
- Use supervised orchestration only when the user explicitly asks you to "supervise", "monitor", "wait", "track completion", "wait for worker_done", return results, coordinate a DAG, use a decision gate, or manage ask/reply flow.
|
||||
- Do not use `orca orchestration dispatch --inject` for full handoffs. It injects a coordinator preamble that tells the worker to send `worker_done`, heartbeat, and `ask` messages, then end its turn under the original terminal's dispatch lifecycle.
|
||||
- Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. Do not peek at terminal output after prompt delivery to monitor progress.
|
||||
- A review-only `worker_done` reports findings; it does not authorize coordinator file edits. After a review-only completion, synthesize findings, ask a decision gate if ownership is unclear, and dispatch or hand off fixes unless the user explicitly asked the coordinator to own fixes.
|
||||
- If the user's plan names a next owner agent (for example, "then use opencode to create a PR"), post-review corrections and PR prep belong to that named owner. The coordinator routes, synthesizes, asks decision gates when needed, and supervises; the named owner edits files and creates the PR.
|
||||
|
||||
If unclear, inspect orchestration state before sending lifecycle messages:
|
||||
|
||||
```bash
|
||||
orca orchestration task-list --json
|
||||
orca terminal list --json
|
||||
# If inherited context includes a task id:
|
||||
orca orchestration dispatch-show --task <task_id> --json
|
||||
```
|
||||
|
||||
## Messaging
|
||||
|
||||
```bash
|
||||
orca orchestration send --subject <text> [--to <run:id|dispatch:id|legacy_handle>] [--from <handle>] [--body <text>] [--type <type>] [--priority <level>] [--thread-id <id>] [--payload <json>] [--json]
|
||||
orca orchestration check [--terminal <handle>] [--ack <delivery_id>] [--peek|--all] [--types <type,...>] [--format] [--wait] [--timeout-ms <n>] [--json]
|
||||
orca orchestration reply --id <msg_id> --body <text> [--from <handle>] [--json]
|
||||
orca orchestration ask (--question <text>|--resume <msg_id>) [--options <csv>] [--timeout-ms <n>] [--from <handle>] [--json]
|
||||
orca orchestration inbox [--limit <n>] [--json]
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Omit `--from` unless impersonating another terminal; Orca auto-resolves it from the current terminal.
|
||||
- A coordinator `check` returns the bound Run's oldest FIFO Delivery (up to 50 messages) and replays that exact batch until `--ack <delivery_id>`. Process every message before acknowledging; `check --ack <id> --wait` acknowledges, checks, and waits in one operation.
|
||||
- Use `--peek` and `--all` only for read-only history/debugging. Type filters decide when a waiter wakes; the returned actionable Delivery is still the oldest full batch.
|
||||
- Use `dispatch:<id>` for coordinator guidance to one supervised worker. Orca routes that stable address locally or through the connected-server relay; do not substitute a remote terminal handle.
|
||||
- Terminal handles remain appropriate for low-level pre-Dispatch messaging. Prefer `agentTerminalHandle` from the create response, fall back to `startupTerminal.handle` for older runtimes, then re-resolve with `orca terminal list --worktree ... --json` if missing or stale. Continue with the replacement handle only; never dual-send to old and new handles.
|
||||
- `terminal list --json` omits `visualLayouts` because handle recovery does not need topology. Add `--include-visual-layouts` only for explicit tab and pane inspection.
|
||||
- `orca orchestration check --peek --format --json` returns locally formatted unread mail without consuming it; it never writes to terminal input or remotely wakes another terminal. Use `orchestration dispatch --inject` to deliver a tracked task, or `terminal send` when an existing agent needs a free-form prompt.
|
||||
- While supervising workers manually, use `check --wait --types worker_done,escalation,question --timeout-ms <n>` instead of sleep/poll loops. Process the whole Delivery, reply to `question` messages with `orca orchestration reply --id <msg_id> --body <answer> --json`, then acknowledge and keep waiting.
|
||||
- `check --json` prints exactly one JSON document on stdout. While `--wait` blocks it also prints keepalive lines (`{"_keepalive":true,...}`) to stderr so you can tell the process is alive; those are never on stdout. Do not merge the streams before a parser — `check --wait --json 2>&1 | <parser>` fails with "Extra data: line 2". Pipe stdout only.
|
||||
- Treat a `check --wait` timeout or `{count:0}` as a checkpoint, not a worker failure. Long coding tasks routinely run 15-60 minutes; keep using rolling waits unless you receive `worker_done`/`escalation`, the terminal exits or disappears, or the user explicitly asks you to stop.
|
||||
- Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet.
|
||||
- Use `ask` when a worker needs a blocking answer from the coordinator; it defaults to the active Dispatch's Run. Timeout or disconnect leaves the question pending, so resume by its original message ID instead of asking again.
|
||||
- `check --wait` returns one bounded Delivery, not every future completion. Process every message, acknowledge it, then keep waiting until every expected Dispatch settles.
|
||||
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:<id>`.
|
||||
- Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `question`, `decision_gate` (legacy/gates), and `heartbeat`.
|
||||
- Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups.
|
||||
- `worker_done` belongs to the active Dispatch and defaults to its Run mailbox; never target a group.
|
||||
- A valid `worker_done` for the active `taskId` + `dispatchId` marks the task and dispatch completed automatically. Do not follow it with `task-update --status completed`; reserve manual updates for explicit recovery or overrides.
|
||||
- `heartbeat` is also Dispatch-scoped. Include both IDs and omit `--to` so Orca uses the owning Run; use `status` for broad progress updates.
|
||||
|
||||
## Tasks And Dispatch
|
||||
|
||||
A Run is the namespace/inbox, a Task is the work item, and a Dispatch assigns one Task attempt to a terminal. Create or bind a Run once before the common loop.
|
||||
|
||||
```bash
|
||||
orca orchestration run-create --objective <text> --json
|
||||
orca orchestration task-create --spec <text> [--deps <json_array>] [--parent <task_id>] [--json]
|
||||
orca orchestration task-list [--status <status>] [--ready] [--brief] [--json]
|
||||
orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--json]
|
||||
orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--inject] [--json]
|
||||
orca orchestration dispatch-show --task <task_id> [--json]
|
||||
```
|
||||
|
||||
Task statuses: `pending`, `ready`, `dispatched`, `completed`, `failed`, `blocked`.
|
||||
|
||||
Dispatch rules:
|
||||
|
||||
- `--inject` sends the task spec plus preamble into a recognized agent CLI so it can report `worker_done`.
|
||||
- If the target is a bare shell, omit `--inject`, dispatch for tracking if needed, then send the prompt manually with `orca terminal send --terminal <handle> --text <prompt> --enter --json`.
|
||||
- After 3 consecutive failures on one task, the dispatch context circuit-breaks and the task is marked failed.
|
||||
- Use `task-list --brief --json` for coordinator sweeps; it collapses whitespace and caps each echoed spec at 160 characters (`spec_truncated` marks shortened rows). Omit `--brief` when the full spec is required, or when an older CLI rejects it as an unknown flag.
|
||||
|
||||
`dispatch` and `worker-start` refuse the following preflight cases with a stable `error.code`; read it before choosing a recovery, and treat `error.data.nextSteps` as the exact recovery text. Older hosts may omit `data`, so treat every field as optional.
|
||||
|
||||
| Code | Meaning | Recovery |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |
|
||||
| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |
|
||||
| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |
|
||||
| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |
|
||||
|
||||
## How deep workers can nest
|
||||
|
||||
A dispatched worker normally cannot dispatch sub-workers. Attempting it fails with
|
||||
`nested_worker_depth_exceeded` and a message telling the worker to complete the task
|
||||
itself. Do that — do not try to route around it.
|
||||
|
||||
The limit is a number, not an on/off switch. `Settings -> Orchestration -> Nested worker depth`
|
||||
sets how many generations are allowed:
|
||||
|
||||
- `1` (default): a coordinator dispatches workers; those workers do not dispatch.
|
||||
- `2`: workers may dispatch one further generation.
|
||||
|
||||
Depth is counted from the terminal that issues the command, not from the Run. Creating a
|
||||
new Run does not reset it — a worker that runs `run-create` then `worker-start` is still a
|
||||
worker, and still counted. This is the part that changed: the old behaviour rejected
|
||||
sub-dispatch only because a worker's terminal was not bound to a Run, so creating a Run was
|
||||
enough to slip past it.
|
||||
|
||||
Two limits worth knowing:
|
||||
|
||||
- **It is a guardrail, not a security boundary.** A caller that declares another terminal's
|
||||
handle while its own launch evidence is unverifiable (an ordinary restored terminal, for
|
||||
example) can be counted as that terminal instead. Orca does not treat workers as hostile.
|
||||
- **It applies while a Dispatch is active.** After `worker_done`, or after a coordinator
|
||||
settles the task, the terminal is no longer a worker and is counted as a root again. The
|
||||
process may still be alive; that is the documented boundary, not an accident.
|
||||
|
||||
## Preferred Supervised Worker Loop
|
||||
|
||||
Use `worker-start` for the normal supervised path. It composes the existing worktree, terminal, readiness, and dispatch primitives while returning exact created/reused effects. Agents still choose placement and concurrency; Orca does not schedule workers or infer conflicts.
|
||||
|
||||
Create the Run and every independent Task first, then start all independent workers before waiting:
|
||||
|
||||
```bash
|
||||
orca orchestration run-create --objective "<objective>" --json
|
||||
orca orchestration task-create --spec "<worker A task>" --json
|
||||
orca orchestration task-create --spec "<worker B task>" --json
|
||||
orca orchestration worker-start --task <task_a> --worktree current --agent codex --json
|
||||
orca orchestration worker-start --task <task_b> --worktree current --agent claude --json
|
||||
```
|
||||
|
||||
`current` and exact existing worktrees create a fresh agent terminal and do not rerun setup. Reuse an existing agent only with `--terminal <handle>`.
|
||||
|
||||
For a per-invocation Claude, Codex, or Cursor launch, pass an opaque provider model id with `--model`; add `--effort` only when that agent/model supports the level. These options apply only to fresh agent terminals, override general agent default arguments, and are reported under `launch.requested` and `launch.effective` in the receipt:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-start --task <task_id> --worktree current --agent claude --model opus --effort high --json
|
||||
```
|
||||
|
||||
`--effort` requires `--model`, and neither option can combine with `--terminal`. A connected worker server must advertise launch-preference support before Orca forwards either option.
|
||||
|
||||
For a new worktree, setup runs by default and agent-first creation reuses the returned startup agent terminal:
|
||||
|
||||
```bash
|
||||
orca orchestration worker-start --task <task_id> --worktree new-child --name <name> --agent codex --setup run --json
|
||||
# Independent/top-level:
|
||||
orca orchestration worker-start --task <task_id> --worktree new-top-level --name <name> --agent codex --setup run --json
|
||||
```
|
||||
|
||||
Setup normally starts alongside the agent. Only a repository explicitly configured with `wait-for-setup` delays agent launch until setup succeeds. Use `--setup skip` or `--setup inherit` only for a concrete reason.
|
||||
|
||||
Read the returned receipt before continuing: `ready` plus setup `running` is normal for start-immediately, while wait-for-setup returns setup `succeeded` before accepting task input. A failed or unknown start exits nonzero; inspect its `stage`, `effects`, and `residualResources` instead of guessing or automatically retrying. A wait-for-setup timeout can honestly leave setup `running`, which is not proof of failure.
|
||||
|
||||
To run the worker on another connected Orca server, add `--on <saved-environment>`. The Run and Tasks remain authoritative on the current server; later commands route by Dispatch ID, so never repeat `--on`:
|
||||
|
||||
```bash
|
||||
# Mac Run home -> Windows worker (the reverse is identical from a Windows Run home)
|
||||
orca orchestration worker-start --task <task_id> --on windows --worktree new-top-level --repo <exact_remote_repo_selector> --name <name> --agent codex --setup run --json
|
||||
orca orchestration worker-show --dispatch <dispatch_id> --json
|
||||
orca orchestration worker-read --dispatch <dispatch_id> --limit 50 --json
|
||||
orca orchestration send --to dispatch:<dispatch_id> --subject "Follow-up" --body "<attempt-specific guidance>" --json
|
||||
```
|
||||
|
||||
Remote `current` and `new-child` are intentionally invalid because those words are ambiguous across servers. Use an exact discovered remote worktree selector or `new-top-level` with an explicit remote repo selector.
|
||||
|
||||
The follow-up is structured inbox mail, not prompt injection. The worker's next
|
||||
`orchestration check` receives it even when the Dispatch is on another connected Orca server.
|
||||
|
||||
`worker-read` defaults to `--source auto`: Orca returns the exact hook-reported Codex, Claude, OpenClaude, or Grok transcript when it can prove the worker session, otherwise it returns bounded terminal output with `source: "terminal"` and a typed `fallbackReason`. Continue with the returned top-level `cursor`; it stays pinned to that exact source. If Orca reports `source_changed`, start a fresh read without the old cursor. Never supply or guess a provider session ID or transcript path.
|
||||
|
||||
Wait until every expected Dispatch settles, not for a fixed number of batches:
|
||||
|
||||
```bash
|
||||
orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
# Process every message. For each accepted worker_done that is not immediately reused:
|
||||
orca orchestration worker-release --dispatch <dispatch_id> --json
|
||||
# Acknowledge only after every message and required release decision is handled:
|
||||
orca orchestration check --ack <delivery_id> --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
After processing each accepted `worker_done`, choose the terminal's next owner before you acknowledge the Delivery or wait again. If the same exact agent has an immediate follow-up Task, read the `worker.agent_terminal_handle` field of `worker-show --dispatch <dispatch_id> --json`, then run `orca orchestration worker-start --task <next_task_id> --terminal <handle> --json` so Orca transfers cleanup ownership to the new Dispatch. Otherwise run `orca orchestration worker-release --dispatch <dispatch_id> --json`.
|
||||
|
||||
Run `worker-release` after both succeeded and failed `worker_done` reports unless the user explicitly asked to keep that worker live. Release is post-completion cleanup, not cancellation: Orca first preserves inspectable output, then closes only the exact agent terminal owned by that settled Dispatch. Reused or pre-existing terminals, setup terminals, coordinators, active workers, user-taken-over terminals, and identities Orca cannot prove are retained. If the user explicitly asks to keep the live terminal for debugging, record that exception with `orca orchestration worker-retain --dispatch <dispatch_id> --json` instead of silently skipping cleanup. When the user is finished, the same Dispatch can be passed to `worker-release`, which clears the requested retention and releases the terminal.
|
||||
|
||||
Do not release a worker because of a timeout, TUI idle state, heartbeat, status, question, escalation, or rejected/stale `worker_done`. If release returns `release_pending` or `release_unknown`, do not substitute `terminal close`; follow the exact recovery action in the receipt. A replayed Delivery may repeat `worker-release` safely.
|
||||
|
||||
Workers report exactly once using the IDs and capability injected by Orca; they do not supply Run/server/terminal identity:
|
||||
|
||||
```bash
|
||||
orca orchestration send --type worker_done --subject "<status>" --body "<what changed, findings, and what remains>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded --files-modified "path/a,path/b" --json
|
||||
# On failure, use --outcome failed; never encode failure only in prose.
|
||||
```
|
||||
|
||||
A worker question defaults to its owning Run. Timeout leaves it pending:
|
||||
|
||||
```bash
|
||||
orca orchestration ask --question "<question>" --options "yes,no" --timeout-ms 600000 --json
|
||||
orca orchestration ask --resume <message_id> --timeout-ms 600000 --json
|
||||
# Coordinator:
|
||||
orca orchestration reply --id <message_id> --body "<answer>" --json
|
||||
```
|
||||
|
||||
Recovery is conditional, never a fixed destructive sequence:
|
||||
|
||||
- The response was lost and named no Dispatch: run `orca orchestration request-show --request <request_id> --json` first. It is read-only. `completed` means the mutation already took effect. `pending` means the original mutation is still running or Orca restarted before recording its outcome. For either state, replaying the original command with `--retry-request <request_id>` reuses the same operation identity so Orca can replay, join, or safely recover it without starting a separate duplicate. `absent` means this runtime holds no receipt under your caller identity and is not proof that nothing happened; inspect the affected state before deciding whether to retry.
|
||||
- `worker-show --dispatch <id>` says `ready`: keep waiting or read bounded output.
|
||||
- It proves `failed` or `stopped`: start a replacement with `worker-start --task <task> --retry-of <id>` plus an explicit `--on`/`--worktree` and `--agent`/`--terminal` choice. Retry does not silently inherit placement.
|
||||
- It remains `outcome_unknown`: either `worker-stop --dispatch <id>` and inspect again, or explicitly `worker-abandon --dispatch <id>` while accepting that resources may still be live. Abandon performs no remote, process, or filesystem action.
|
||||
- `worker-stop` closes only the exact supervised agent terminal. It never deletes the worktree, setup terminal, configured tabs, or unrelated processes.
|
||||
|
||||
Low-level `worktree create`, `terminal create`, and `dispatch --inject` remain valid recipes for custom argv or topology that `worker-start` does not express.
|
||||
|
||||
`dispatch --inject` deliberately keeps an operator-started terminal unsupervised: it never creates a `worker_dispatches` row and `worker-stop`/`worker-abandon` never close that process. The dispatch context is still authoritative, so `worker-show`, `worker-read`, and `worker-list` report it as `unsupervised`; settled `worker-retain` and `worker-release` report `retained` with `no_owned_resource` and take no process action. Use `worker-start --terminal <handle>` when supervision and worker lifecycle state are required.
|
||||
|
||||
## Gates And Legacy Inspection
|
||||
|
||||
```bash
|
||||
orca orchestration gate-create --task <task_id> --question <text> [--options <json_array>] [--json]
|
||||
orca orchestration gate-resolve --id <gate_id> --resolution <text> [--json]
|
||||
orca orchestration gate-list [--task <task_id>] [--status <status>] [--json]
|
||||
```
|
||||
|
||||
Use `ask` for worker-to-coordinator questions; it creates a `question` message that the coordinator answers with `reply`. Use `gate-create` only for coordinator-managed task DAG decisions, not for answering a worker's `ask`.
|
||||
|
||||
`coordinator-start`, `coordinator-stop`, `run`, and `run-stop` are retired scheduler commands. They perform no effects and return the current-skill recovery action. They are not aliases for lightweight Run creation or binding.
|
||||
|
||||
Recovery only: `orca orchestration reset --tasks|--messages|--all --json` clears the selected local orchestration database state. Do not run it during active coordination unless explicitly abandoning that state.
|
||||
|
||||
## Full Handoffs
|
||||
|
||||
For full ownership transfer, use non-lifecycle terminal/worktree commands and then stop monitoring unless the user asks for supervision.
|
||||
|
||||
Treat these as full handoff requests by default: "hand off", "handoff", "handover", "give this to another agent", "give this to another worktree", "send this to another agent", "another agent", "another worktree", or "launch another agent to own this." Custom model or reasoning effort words such as `gpt-5.5`, `high`, or `xhigh` do not make the handoff supervised.
|
||||
|
||||
Supervised orchestration remains available only when the user explicitly asks for supervision or coordination: "supervise", "monitor", "wait for worker_done", "wait for results", "track completion", "DAG", "decision gate", "ask/reply", or "coordinate workers."
|
||||
|
||||
Do not run `orca orchestration task-create`, `orca orchestration dispatch --inject`, or `orca orchestration check --wait` for full handoffs. `task-create` is also forbidden because it records coordinator-owned tracking state; if a task row is needed, the user asked for supervised orchestration. Do not create a `taskId`/`dispatchId`, inject a lifecycle preamble, wait for completion, or read the worker terminal after prompt delivery except to avoid losing the initial prompt.
|
||||
|
||||
New top-level worktree handoff:
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --no-parent --agent codex --prompt "<task brief>" --setup run --json
|
||||
```
|
||||
|
||||
Before creating a new worktree from an active feature branch, decide and state whether the desired Orca lineage is child or top-level. Use child worktree lineage only when the new work is conceptually stacked under or dependent on the active worktree. For independent repo-wide fixes, standalone feature work, or unrelated follow-up tasks, create a top-level worktree with `--no-parent`.
|
||||
|
||||
Existing terminal handoff:
|
||||
|
||||
```bash
|
||||
orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
```
|
||||
|
||||
Custom Codex model/effort handoff:
|
||||
|
||||
`orca worktree create --agent codex --prompt ...` launches the known Codex agent but does not accept Codex-specific `--model` or `-c model_reasoning_effort=...` arguments. When the user asks for a specific Codex model or effort, create the independent worktree first, launch Codex with the requested command in that worktree, wait only for TUI readiness if prompt delivery would otherwise race startup, send the prompt, and stop.
|
||||
|
||||
The two-step custom-argv path cannot enforce a repository's explicit `wait-for-setup` startup policy because the later `terminal create` is not the startup owned by `worktree create`. Use it only when the repository starts agents immediately. If the repository requires `wait-for-setup`, use an agent-first configured launcher that can preserve sequencing, or stop and ask rather than silently bypassing the policy.
|
||||
|
||||
Note: when no repo default-terminal configuration supplies a primary terminal, bare create opens a fallback shell before `terminal create` adds the agent. Configured default tabs are materialized instead and may run real commands. Prefer `--agent` whenever custom argv is not required. With the two-step path, target only the agent handle; close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell.
|
||||
|
||||
Use the exact full `<repo-id>::<path>` worktree id returned by `orca worktree create --json`; a bare repo id cannot target the new worktree.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --no-parent --setup run --json
|
||||
orca terminal create --worktree id:<newFullWorktreeId> --title <task-name> --command 'codex --model gpt-5.5 -c model_reasoning_effort="xhigh"' --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca terminal send --terminal <handle> --text "<task brief>" --enter --json
|
||||
```
|
||||
|
||||
Wait only for `tui-idle` when needed to avoid losing the prompt. Do not monitor task completion.
|
||||
|
||||
`--no-parent` only controls Orca lineage; it does not choose the Git base. If the work should start from the repo default base, omit `--base-branch` so Orca uses that default, or explicitly pass the repo default base (`origin/main`, `origin/master`, or the `orca repo show --repo <selector> --json` value); never base it on the current feature branch unless the user explicitly asks for stacked work or "branch from current". Put current-branch context in the prompt instead.
|
||||
|
||||
## Worker Terminals
|
||||
|
||||
Choose the worker location before creating a terminal. `Fresh worker` means a fresh agent session, not a new git worktree. For parallel work, create one fresh agent terminal per worker in the same required worktree, falling back to the active worktree when none is named. If the task says current worktree only, depends on uncommitted files/artifacts, or must validate/PR the current branch, keep every worker in the active worktree:
|
||||
|
||||
```bash
|
||||
orca terminal create --worktree active --title <task-name> --command "codex" --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
Reuse an idle agent in the required worktree only if the prompt allows reuse; otherwise create a fresh terminal there. Create a new worktree only when the user explicitly requests one or a concrete checkout or filesystem conflict makes sharing unsafe or impossible; if the user did not request it, state that conflict before running `worktree create`. Independent tasks, parallel execution, convenience, or a preference for separate checkouts are not isolation requirements.
|
||||
|
||||
When a new worktree is allowed, use child lineage for isolated work that is stacked under or dependent on the active worktree, and use `--no-parent` when it is not stacked. Decide the Git base separately: `--no-parent` makes the worktree top-level in Orca, while omitted `--base-branch` uses the repo default base.
|
||||
|
||||
For every new worktree, pass `--setup run` so any configured repository setup hook runs. This does not mean waiting for setup before agent launch: preserve the repository's startup policy, whose default starts setup and the agent side by side. Use `--setup skip` or `--setup inherit` only when there is a concrete task-specific reason, and state that reason before creating the worktree. This rule does not rerun setup for current or existing worktrees.
|
||||
|
||||
```bash
|
||||
orca worktree create --name <task-name> --agent codex --setup run --json
|
||||
# or: --agent claude | omp | pi | grok | ...
|
||||
# Read <handle> from agentTerminalHandle, falling back to startupTerminal.handle.
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
For new-worktree workers, read the id and `agentTerminalHandle` from `worktree create`, falling back to `startupTerminal.handle` for older runtimes. Use that as the sole worker handle when present; otherwise use `terminal list` to resolve the agent handle. Omit `--repo` only inside an Orca-managed worktree; otherwise pass `--repo <selector>`.
|
||||
|
||||
**For an allowed new worktree, use agent-first:** `--agent` reveals the new worktree and launches the selected agent **in its first terminal**, without adding a separate fallback shell for that worker. Pass `--setup run`; repo setup and default-terminal settings may add intentional tabs or splits. Do **not** run bare `worktree create` and then `terminal create --command <agent>` for the same worker when agent-first create is available: without configured default tabs, that two-step path leaves a fallback shell + agent pair. Only use it when custom agent argv is required (for example Codex model/effort flags) or when an older CLI rejects `--agent`; if you must, message only the agent handle. Configured default tabs are intentional surfaces, so close a prior terminal only after `terminal list` or `terminal show` confirms it is an unused shell. Do not run `worktree create` when the task must stay in the current worktree.
|
||||
|
||||
Use `orca worktree create --prompt ...` or `orca terminal send ...` for full handoffs or untracked/lightweight prompts. Those paths do not attach `taskId`/`dispatchId`; the worker should not send lifecycle messages unless the prompt supplies a live orchestration preamble.
|
||||
|
||||
Sidebar lineage and orchestration lifecycle are related but not identical. A same-worktree worker may appear as a peer under that worktree in the sidebar while remaining a child dispatch in orchestration state; only an actual child worktree creates visible parent/child worktree lineage.
|
||||
|
||||
Other terminal commands coordinators often need:
|
||||
|
||||
```bash
|
||||
orca terminal list [--worktree <selector>] [--include-visual-layouts] [--json]
|
||||
orca terminal create [--worktree <selector>] [--title <text>] [--command <cmd>] [--json]
|
||||
orca terminal split --terminal <handle> [--direction horizontal|vertical] [--command <cmd>] [--json]
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms <n> --json
|
||||
orca terminal read --terminal <handle> --json
|
||||
orca terminal send --terminal <handle> --text <text> --enter --json
|
||||
```
|
||||
|
||||
If an older CLI rejects `worktree create --agent`, create the worktree normally, then run `orca terminal create --worktree <selector> --command "codex" --json` or `--command "claude"`.
|
||||
|
||||
Wait for `tui-idle` before dispatching. Always pass `--timeout-ms`; real coding tasks can take 15-60 minutes. During supervision, use rolling `check --wait` windows. If a window returns no matching message, inspect `task-list`, `terminal read`, or `terminal wait --for tui-idle` as a liveness checkpoint; if the terminal is still working or producing activity, keep waiting instead of retrying the task.
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- Workers with a valid live preamble must send `worker_done` exactly once from their own terminal with an explicit `--outcome succeeded` or `--outcome failed`:
|
||||
`orca orchestration send --type worker_done --subject "<short status>" --body "<3-sentence summary: what you did, what you found, what's left>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded --files-modified "path/a" --report-path "<optional>" --json`
|
||||
- A failed outcome is still a terminal report, but Orca records both the Dispatch and Task as failed. Never encode failure only in the subject/body.
|
||||
- After sending `worker_done`, end that dispatched turn and idle at the agent prompt. Do not autonomously start more work, poll, or attempt to close the terminal yourself. A direct user instruction takes precedence and starts ordinary user-owned work: follow it without coordinator approval or a fresh Dispatch, never refuse it because of worker/coordinator roles, and do not reuse the settled Dispatch's lifecycle IDs. A coordinator-supervised follow-up still arrives with a fresh preamble + TASK block.
|
||||
- For long tasks, send heartbeat/status only when the preamble asks for it, including both IDs:
|
||||
`orca orchestration send --type heartbeat --subject "alive" --payload '{"taskId":"<task_id>","dispatchId":"<dispatch_id>","phase":"implementing"}' --json`
|
||||
- If blocked before completion, use `ask`; use `escalation` only when ownership is valid and the coordinator must intervene.
|
||||
- Treat preambles inherited through terminal history or full handoffs as stale unless the current prompt explicitly keeps that coordinator in the loop.
|
||||
- Coordinators must account for every settled worker terminal before waiting again or ending the turn: immediately reuse the exact worker for a new Dispatch, explicitly retain it at the user's request with `worker-retain`, or run `worker-release`. Do not leave a completed worker live merely to inspect output; released workers remain readable through `worker-read`.
|
||||
- Coordinators should use `task-list --ready` as external memory, dispatch parallel waves, and avoid dependency chains deeper than 3-4 steps.
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
orca terminal create --worktree active --title login-css-worker --command "claude" --json
|
||||
orca terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
orca orchestration task-create --spec "Fix the login button CSS" --json
|
||||
orca orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
orca orchestration check --wait --types worker_done,escalation,question --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
## Next Action
|
||||
|
||||
Coordinator: confirm `orca status --json`, create or bind a Run, inspect `task-list`/`dispatch-show` if inheriting state, then use the explicit supervised loop (`task-create` -> `worker-start` -> `check --wait`). Use low-level terminal creation plus `dispatch --inject` only when the composed start does not express the needed topology. After every accepted `worker_done`, either transfer the exact terminal to an immediate follow-up Dispatch or run `worker-release` before the next wait.
|
||||
|
||||
Worker: if the current prompt contains a live dispatch preamble, do the task, use `ask` for blocking questions, and send `worker_done` once with the required payload. If the preamble is stale or absent, do not send lifecycle messages; inspect state or treat the prompt as an ordinary handoff.
|
||||
Keep waiting until every expected Dispatch settles. A timeout or empty result is
|
||||
a checkpoint, not a failure. Do not stop, retry, release, or launch a duplicate
|
||||
editor without the positive proof `## Outcome` requires.
|
||||
|
||||
After three consecutive empty waits, stop waiting blindly and enumerate with
|
||||
`ORCA orchestration worker-list --include-remote --json` (defaults to the bound
|
||||
Run; `--run <run_id>` overrides; the receipt's `scope` names which), acting on
|
||||
each row's `projection.attention` categories, `projection.attention.requiresAction`, and literal `projection.nextAction` argv.
|
||||
An `inspect` `nextAction` on a `live` row with `attention.requiresAction` false
|
||||
is informational, not a command to re-run: keep waiting with `check --wait`.
|
||||
Leave the wait only on positive proof the agent stopped: `exited` liveness, the
|
||||
worker's own observation of process exit, or a transcript whose final agent turn
|
||||
sent no `worker_done`. Then load `references/recovery-and-cleanup.md` and choose
|
||||
`worker-stop` or `worker-abandon` explicitly. `unverifiable` is absence,
|
||||
including when `worker-show` reports `agentWait` null. Absence never authorizes
|
||||
stop, abandon, retry, or release; keep waiting or inspect.
|
||||
|
||||
`worker-start` is the normal path, composing placement, terminal readiness,
|
||||
prompt injection, and supervised resource ownership. `dispatch --inject` leaves
|
||||
an operator-created process unsupervised and is only for an expressiveness gap.
|
||||
|
||||
## Task-spec contract
|
||||
|
||||
Every Task spec must be self-contained and name:
|
||||
|
||||
- **Target:** the files, component, or environment in scope.
|
||||
- **Change:** the concrete result to produce.
|
||||
- **Constraints:** invariants, compatibility rules, and do-not-touch boundaries.
|
||||
- **Ownership:** what this worker may edit and any coordination boundary.
|
||||
- **Observable acceptance:** the test, output, or evidence that proves completion.
|
||||
|
||||
## Completion accounting
|
||||
|
||||
After an accepted success or failure report, immediately do exactly one:
|
||||
|
||||
1. Reuse the same proven agent terminal for an immediate follow-up Dispatch.
|
||||
2. Record user-requested retention with `worker-retain`.
|
||||
3. Run `worker-release`.
|
||||
|
||||
Release is post-settlement cleanup, not cancellation. Only an accepted
|
||||
settlement authorizes it; no other observation does. If release is uncertain,
|
||||
follow its exact recovery receipt and never substitute `terminal close`.
|
||||
|
||||
A valid `worker_done` settles the Task and Dispatch automatically; do not follow
|
||||
it with `task-update --status completed`. Enumerate the terminals still owing a
|
||||
decision with `worker-list --run <run_id> --terminal-state reclaimable --json`,
|
||||
and do not end the coordinator turn until it returns none.
|
||||
|
||||
## Conditional references
|
||||
|
||||
This compact guide is sufficient for the normal local loop. At an action gate
|
||||
below, run `ORCA skills get orchestration --reference references/<file>.md` and
|
||||
read only that document; `--references` lists the names. If the CLI rejects
|
||||
`--reference`, run `ORCA skills get orchestration --full` once instead: it
|
||||
returns this exact kernel and every reference, so read only the named one. If an
|
||||
older CLI rejects `--full`, keep this kernel's safety floor, use that command's
|
||||
`--help`, and never guess newer flags.
|
||||
|
||||
| Action gate | Bundled reference |
|
||||
| ------------------------------------------------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| Expanded DAG waves, launch model/effort, same-terminal reuse, or review ownership | `references/coordinator-loop.md` |
|
||||
| You are a dispatched worker and the live preamble does not answer your question, or `check` returned an error | `references/worker-contract.md` |
|
||||
| New worktree, exact workspace, SSH, WSL, or connected-server placement | `references/placement-and-remote.md` |
|
||||
| Inbox replay, follow-up messages, group addresses, or decision gates | `references/messaging-and-gates.md` |
|
||||
| Failed/stopped/unknown attempts, retry, stop, abandon, retain, or uncertain release | `references/recovery-and-cleanup.md` |
|
||||
| Custom argv or terminal topology that `worker-start` cannot express | `references/low-level-topology.md` |
|
||||
| Any legacy label, adopted Run, compatibility receipt, or takeover | `references/legacy-contract-migration.md` |
|
||||
|
||||
Retired scheduler commands are not aliases for Run creation. Recovery commands
|
||||
must provide their exact next action; follow it with the same selected executable.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Coordinator loop
|
||||
|
||||
Load this reference for expanded DAG waves, per-invocation launch preferences,
|
||||
same-terminal reuse, or review ownership. The compact guide remains the source
|
||||
of truth for the loop order and completion boundary.
|
||||
|
||||
## Ready waves
|
||||
|
||||
Create independent Tasks before the first wait. Encode only real dependencies,
|
||||
then use the ready view as external memory:
|
||||
|
||||
```text
|
||||
ORCA orchestration task-create --spec "<dependent work>" --deps <json_array> --json
|
||||
ORCA orchestration task-list --ready --brief --json
|
||||
```
|
||||
|
||||
`--brief` collapses whitespace and caps echoed specs at 160 characters;
|
||||
`spec_truncated` identifies shortened rows. Omit it when full specs are needed or
|
||||
when an older CLI rejects the flag. A nested worker must respect
|
||||
`nested_worker_depth_exceeded`; creating another Run does not reset depth.
|
||||
|
||||
## Launch preferences
|
||||
|
||||
For a fresh Claude, Codex, or Cursor terminal, `--model` accepts an opaque
|
||||
provider model ID. Pick the cheapest model that fits the Task (`sonnet` for
|
||||
routine work); an omitted model inherits the launcher's default, often the most
|
||||
expensive. Add `--effort` only when that model supports it:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-start --task <task_id> --worktree current --agent claude --model sonnet --json
|
||||
ORCA orchestration worker-start --task <task_id> --worktree current --agent claude --model opus --effort high --json
|
||||
```
|
||||
|
||||
`--effort` requires `--model`; neither option combines with `--terminal`. A
|
||||
connected worker server must advertise launch-preference support before Orca
|
||||
forwards either field. Compare `launch.requested` with `launch.effective`; never
|
||||
claim a model or effort from requested arguments alone.
|
||||
|
||||
## Reuse after settlement
|
||||
|
||||
Choose the terminal's next owner before acknowledging the Delivery. When the
|
||||
same exact agent has immediate follow-up work, recover the proven handle and
|
||||
transfer cleanup ownership to the new Dispatch:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-show --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-start --task <next_task_id> --terminal <agent_terminal_handle> --json
|
||||
```
|
||||
|
||||
Otherwise explicitly retain or release the settled worker. Do not leave it live
|
||||
only to inspect output; archived output remains available through `worker-read`.
|
||||
|
||||
## Review ownership
|
||||
|
||||
A review-only `worker_done` authorizes synthesis of findings, not coordinator
|
||||
file edits. Dispatch or hand off fixes unless the user explicitly assigned them
|
||||
to the coordinator. If the user's plan names a next owner, post-review fixes and
|
||||
PR preparation remain with that owner; the coordinator routes and synthesizes.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Legacy contract migration
|
||||
|
||||
Load this reference only for an authority label, adopted Run, compatibility or
|
||||
recovery receipt, or explicit legacy takeover. A newly created attempt always
|
||||
uses the current grammar.
|
||||
|
||||
## Authority labels
|
||||
|
||||
- `[LEGACY COMPATIBILITY]` is live and attested. Run only the exact supported
|
||||
command printed with the message, using the same selected executable and
|
||||
arguments supplied by the original prompt.
|
||||
- `[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]` is one bounded,
|
||||
at-least-once cutover replay. Process it idempotently and acknowledge only
|
||||
through the exact displayed guidance.
|
||||
- `[LEGACY READ-ONLY]` is inspection-only. It has no reply, acknowledgment, or
|
||||
lifecycle mutation.
|
||||
- An unlabeled current message uses the current guide and grammar.
|
||||
|
||||
An explicitly selected current Run, attested current binding, current Dispatch,
|
||||
or federated attachment takes precedence over legacy fallback. A retained
|
||||
adoption record alone does not grant mutation authority. If liveness, principal
|
||||
ownership, capability, or the exact legacy contract is unproven, degrade to
|
||||
read-only inspection and never fall back to local execution.
|
||||
|
||||
Adoption preserves the live agent process, PTY/session, terminal handle,
|
||||
tab/pane, worktree or folder workspace, Task, and Dispatch. It never restarts or
|
||||
replaces the worker and never revives the retired scheduler. Loss of lifecycle
|
||||
authority does not invalidate the existing process, assignment, or filesystem
|
||||
work. Exact recovery may restore the same PTY once in its original inactive
|
||||
background tab; it must not spawn, write, signal, stop, switch, focus, split, or
|
||||
inject a terminal.
|
||||
|
||||
## Compatibility recovery
|
||||
|
||||
When a compatibility response returns structured next-step arguments, execute
|
||||
those exact arguments with the same selected CLI executable. Do not translate
|
||||
from memory, broaden the recipient, or retry as a current mutation unless the
|
||||
receipt explicitly authorizes it.
|
||||
|
||||
A pending ask, reply, final Dispatch settlement, and consuming check have
|
||||
durable recovery identities. Heartbeat and escalation remain at-least-once
|
||||
across a manual contract-boundary retry. If an ask may already have been
|
||||
answered, run the exact non-consuming recovery check printed by Orca before
|
||||
creating any new question. Never guess among identical question threads.
|
||||
|
||||
On packaged Windows, a legacy ask uses a two-step commit/resume protocol. The
|
||||
initial command commits the question, prints its exact
|
||||
`ask --resume <message_id>` command, and exits with launcher status `75`. Run
|
||||
that exact resume after the launcher or update boundary. For an attested WSL
|
||||
launch, preserve the printed `orca-ide` executable and distro route. Older WSL
|
||||
workers without launch proof remain lifecycle read-only even while their
|
||||
terminal and filesystem work continue.
|
||||
|
||||
## Read-only inspection and takeover
|
||||
|
||||
Read-only inspection does not consume mail:
|
||||
|
||||
```text
|
||||
ORCA orchestration run-list --json
|
||||
ORCA orchestration run-show --id run_legacy_local --json
|
||||
ORCA orchestration run-show --id <adopted_run_id> --json
|
||||
ORCA orchestration task-list --run <adopted_run_id> --json
|
||||
ORCA orchestration inbox --full --json
|
||||
ORCA orchestration check --terminal <legacy_handle> --peek --format --json
|
||||
ORCA terminal read --terminal <legacy_handle> --json
|
||||
ORCA terminal wait --terminal <legacy_handle> --for tui-idle --timeout-ms 60000 --json
|
||||
```
|
||||
|
||||
`run_legacy_local` is an empty audit tombstone after adoption. Find the ordinary
|
||||
Run whose objective is `Recovered orchestration work from a contract update`.
|
||||
|
||||
Only when the original coordinator is unavailable or cannot prove retained
|
||||
authority may a new live coordinator take over from its own terminal:
|
||||
|
||||
```text
|
||||
ORCA orchestration run-use --id <adopted_run_id> --takeover-legacy --json
|
||||
ORCA orchestration check --run <adopted_run_id> --json
|
||||
```
|
||||
|
||||
Takeover binds the authenticated invoking terminal; `--from` cannot nominate
|
||||
another coordinator. It fences only the old coordinator and moves pending mail
|
||||
into current Run delivery. It preserves live workers, Tasks, Dispatches, processes, and files.
|
||||
Never take over while the original coordinator is actively coordinating.
|
||||
|
||||
Do not launch a replacement editor merely because Orca updated or authority is
|
||||
unclear. Keep the original worker as the only editor until a stable handoff
|
||||
point, then use a fresh current Dispatch in a conflict-free placement.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Low-level topology
|
||||
|
||||
Load this reference only when `worker-start` cannot express required custom argv
|
||||
or terminal topology. It is not the normal supervised loop and is never a full
|
||||
handoff recipe.
|
||||
|
||||
```text
|
||||
ORCA terminal create --worktree active --title <task_name> --command "<agent_command>" --json
|
||||
ORCA terminal wait --terminal <handle> --for tui-idle --timeout-ms 60000 --json
|
||||
ORCA orchestration dispatch --task <task_id> --to <handle> --inject --json
|
||||
```
|
||||
|
||||
Wait for readiness only when startup could lose injected input. Prefer
|
||||
agent-first `worker-start` whenever its argv and topology are sufficient.
|
||||
|
||||
`dispatch --inject` creates authoritative Task/Dispatch context but deliberately
|
||||
keeps an operator-created process unsupervised: it creates no supervised worker
|
||||
resource row. `worker-show`, `worker-read`, and `worker-list` report the lane as
|
||||
`unsupervised`; `worker-stop` and `worker-abandon` do not close that process, and
|
||||
settled retain/release take no process action.
|
||||
|
||||
Use `worker-start --terminal <handle>` when lifecycle ownership of an existing
|
||||
agent terminal is required. Never imply that low-level dispatch retroactively
|
||||
owns a process, never use it to route around the nested-depth limit, and never
|
||||
use it for an ownership handoff.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Messaging and gates
|
||||
|
||||
Load this reference for inbox replay, attempt-specific guidance, group
|
||||
addresses, blocking questions, or coordinator-managed DAG decisions.
|
||||
|
||||
A successful `send` proves durable enqueue. Wake and nudge are best-effort
|
||||
attention only: neither proves the recipient read the message, began a turn, or
|
||||
accepted steering.
|
||||
|
||||
## Coordinator delivery loop
|
||||
|
||||
`check` names its caller with `--terminal <handle>` and is the only verb that
|
||||
rejects `--from`. Omit `--terminal` inside an Orca terminal, where Orca resolves
|
||||
the caller; pass it explicitly from anywhere else, including a dispatched
|
||||
worker reading coordinator follow-ups.
|
||||
|
||||
A consuming coordinator `check` returns the bound Run's oldest FIFO Delivery,
|
||||
up to 50 messages, and replays that exact batch until acknowledged. Process
|
||||
every row and required terminal ownership decision before `--ack`. Type filters
|
||||
decide when a waiter wakes; they do not authorize skipping older actionable
|
||||
mail. A Delivery therefore always carries the whole FIFO batch whatever its
|
||||
types, and a `check` without `--wait` hands that batch over unfiltered.
|
||||
`--peek` and `--all` are read-only inspection, not progress through the
|
||||
coordinator inbox.
|
||||
|
||||
An empty wait or timeout is a checkpoint. Continue rolling waits until every
|
||||
expected Dispatch settles. Heartbeat or visible activity means alive, not done.
|
||||
|
||||
## Addresses
|
||||
|
||||
Use a stable Dispatch address for attempt-specific coordinator guidance:
|
||||
|
||||
```text
|
||||
ORCA orchestration send --to dispatch:<dispatch_id> --subject "Follow-up" --body "<guidance>" --json
|
||||
```
|
||||
|
||||
Do not substitute a remote terminal handle. Omit `--from` for ordinary
|
||||
coordinator calls; a dispatched worker instead copies the exact `--from` and
|
||||
capability arguments in its preamble. `check` is the exception: it identifies
|
||||
its caller with `--terminal`, never `--from`.
|
||||
|
||||
Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`,
|
||||
`@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:<id>`. Use them only for
|
||||
intentional fan-out status or questions. `worker_done`, heartbeat, and other
|
||||
Dispatch lifecycle messages never target groups.
|
||||
|
||||
## Questions and gates
|
||||
|
||||
A worker uses `ask`; its timeout leaves one durable question pending, which the
|
||||
worker resumes by message ID. The coordinator answers that message with `reply`.
|
||||
|
||||
Use a gate only for a coordinator-owned Task-DAG decision:
|
||||
|
||||
```text
|
||||
ORCA orchestration gate-create --task <task_id> --question "<decision>" --options <json_array> --json
|
||||
ORCA orchestration gate-resolve --id <gate_id> --resolution "<choice>" --json
|
||||
ORCA orchestration gate-list --task <task_id> --json
|
||||
```
|
||||
|
||||
Pass `json_array` using the quoting rules of the active shell; do not copy POSIX
|
||||
single-quote syntax into PowerShell or `cmd.exe`.
|
||||
|
||||
Do not create a gate merely to answer a worker's `ask`.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Placement and remote execution
|
||||
|
||||
Load this reference before creating a new worktree or placing work through SSH,
|
||||
WSL, or another connected Orca server.
|
||||
|
||||
## Placement choices
|
||||
|
||||
A fresh worker means a fresh agent terminal, not a new Git worktree. Use the
|
||||
current or an exact existing workspace by default. Create a worktree only when
|
||||
the user requested one or a concrete checkout or filesystem conflict makes
|
||||
sharing unsafe.
|
||||
|
||||
```text
|
||||
# Current workspace; setup is not rerun.
|
||||
ORCA orchestration worker-start --task <task_id> --worktree current --agent codex --json
|
||||
|
||||
# Stacked child worktree.
|
||||
ORCA orchestration worker-start --task <task_id> --worktree new-child --name <name> --agent codex --setup run --json
|
||||
|
||||
# Independent top-level worktree.
|
||||
ORCA orchestration worker-start --task <task_id> --worktree new-top-level --name <name> --agent codex --setup run --json
|
||||
```
|
||||
|
||||
Current and exact existing workspaces create a fresh terminal unless
|
||||
`--terminal` is explicit. Folder workspaces are first-class; do not invoke Git
|
||||
or require worktree lineage when the selected workspace is a folder.
|
||||
|
||||
Register a folder workspace through project setup. `repo add --path <dir>`
|
||||
requires a valid Git repository and rejects a plain directory:
|
||||
|
||||
```text
|
||||
ORCA project setup-existing-folder --project <project_id> --host <host_id> --path <abs_path> --kind folder --json
|
||||
```
|
||||
|
||||
Then place work on the returned workspace with an exact selector. A worktree
|
||||
selector needs the full `<repo-id>::<path>` value Orca returned, passed as
|
||||
`id:<newFullWorktreeId>`; a bare repo id is not a worktree id. `new-child` and
|
||||
`new-top-level` are worktree creation and do not apply to a folder.
|
||||
|
||||
New worktrees use agent-first creation and run setup by default. Preserve the
|
||||
repository's startup policy: `start-immediately` can report setup as `running`,
|
||||
while `wait-for-setup` gates prompt delivery on success. Orca lineage, Git base,
|
||||
filesystem isolation, coordination parentage, UI grouping, and execution host
|
||||
are separate decisions.
|
||||
|
||||
## Connected servers
|
||||
|
||||
The Run and Tasks remain authoritative on the current server. `--on` selects
|
||||
only the worker's execution server and appears only on `worker-start`:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-start --task <task_id> --on <environment> --worktree new-top-level --repo <exact_remote_repo_selector> --name <name> --agent codex --setup run --json
|
||||
```
|
||||
|
||||
Remote `current` and `new-child` are invalid because they are ambiguous across
|
||||
servers. Use an exact discovered remote workspace, or `new-top-level` with an
|
||||
exact remote repository selector. After start, route every follow-up, read,
|
||||
stop, and cleanup by Dispatch ID; never repeat `--on` or substitute a remote
|
||||
terminal handle.
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-show --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-read --dispatch <dispatch_id> --limit 50 --json
|
||||
ORCA orchestration send --to dispatch:<dispatch_id> --subject "Follow-up" --body "<guidance>" --json
|
||||
ORCA orchestration worker-list --run <run_id> --include-remote --json
|
||||
```
|
||||
|
||||
`worker-list` reads local fleet state only; enumerate remote workers with
|
||||
`--include-remote` or every one of them reads `unverifiable`. Scope every list
|
||||
with `--run <run_id>`: unscoped, it reports every Dispatch this runtime has
|
||||
recorded, and the workers you are waiting on are lost in that history.
|
||||
|
||||
## Execution-host and mixed-version floor
|
||||
|
||||
The execution host owns process, filesystem, transcript, stop, and cleanup
|
||||
facts. Render only `live`, `unverifiable`, or `exited`. Connection loss, relay
|
||||
absence, missing client inventory, or timeout yields `unverifiable`, never
|
||||
synthetic exit and never a client-local substitute action.
|
||||
|
||||
Clients and servers update independently. Optional response fields may be
|
||||
absent. Forward model/effort, transcript reads, cleanup, or another new remote
|
||||
operation only when the peer advertises the relevant capability; unknown stream
|
||||
opcodes can be silently dropped. A narrow unsupported response may degrade to a
|
||||
documented older path, but must not broaden the target or cross the execution
|
||||
boundary. Changing host-published content reaches old clients even without a
|
||||
wire-shape change, so preserve established semantics or negotiate the behavior.
|
||||
|
||||
For WSL, use the exact executable and arguments returned by Orca so the distro
|
||||
and packaged launcher remain bound. Do not translate a printed `orca-ide`
|
||||
recovery command into a PATH-resolved local command.
|
||||
@@ -0,0 +1,159 @@
|
||||
# Recovery and cleanup
|
||||
|
||||
Load this reference only after a failed/stopped/unknown attempt, explicit retry
|
||||
decision, stop/abandon request, retention request, or uncertain release.
|
||||
|
||||
| Proven state | Safe action |
|
||||
| ----------------------- | ------------------------------------------------------------------ |
|
||||
| `ready` or active | Keep waiting; optionally read bounded output |
|
||||
| `failed` or `stopped` | Start a replacement with `--retry-of`; repeat placement explicitly |
|
||||
| `outcome_unknown` | Inspect, then choose `worker-stop` or explicit `worker-abandon` |
|
||||
| Accepted `worker_done` | Reuse, retain, or release |
|
||||
| Remote contact lost | Preserve `unverifiable`; do not stop or retry from absence alone |
|
||||
| `unverifiable` liveness | Keep waiting or inspect; never stop, abandon, retry, or release |
|
||||
| Proven `exited` agent | Enumerate with `worker-list`; follow its `nextAction` |
|
||||
|
||||
## Inspect before acting
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-list --run <run_id> --json
|
||||
ORCA orchestration worker-list --run <run_id> --include-remote --json
|
||||
ORCA orchestration worker-show --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-read --dispatch <dispatch_id> --limit 50 --json
|
||||
```
|
||||
|
||||
`worker-list` is the enumerating command and the authority on agent liveness:
|
||||
each row carries `projection.liveness`, `projection.attention.categories`,
|
||||
`projection.attention.requiresAction`, and a literal `projection.nextAction`
|
||||
argv to run. Always scope it with `--run <run_id>`; an unscoped list reports
|
||||
every Dispatch this runtime has ever recorded and buries the live ones.
|
||||
`worker-show`'s `observation.status` is PTY liveness only, so a `live` terminal
|
||||
whose agent died at a trust prompt still reads `live` there.
|
||||
|
||||
When the two disagree, the fleet verdict decides — unless the fleet row is
|
||||
`unverifiable` for a reason that names a gap on this client rather than a fact
|
||||
about the worker. `missing_status`, `host_unavailable`, and
|
||||
`capability_unsupported` are such gaps: the first means this runtime holds no
|
||||
status row, the second that it could not ask the execution host at all, and the
|
||||
third that a stale peer answered but lacks the fleet-snapshot capability.
|
||||
Against any of them, a `worker-show` verdict sourced from the execution host is
|
||||
the better evidence and outranks the row. Only `host_unavailable` is contact
|
||||
loss; the other two mean the host was never asked or answered without the
|
||||
capability.
|
||||
|
||||
This never promotes absence. `unverifiable` from either command still authorizes
|
||||
nothing — only a positive `live` or `exited` verdict does.
|
||||
|
||||
A worker started with `--on <environment>` reads `unverifiable` until you
|
||||
enumerate with `--include-remote`, which asks its execution host for the
|
||||
verdict. Past 100 rows the response pages, so follow `page.nextCursor` with
|
||||
`--cursor <value>` until `page.hasMore` is false.
|
||||
|
||||
## Stall needs positive evidence
|
||||
|
||||
Leave the wait only on positive proof the agent stopped: `exited` liveness, the
|
||||
worker's own observation of process exit, or a transcript whose final agent turn
|
||||
sent no `worker_done`. Only then choose `worker-stop` or `worker-abandon`.
|
||||
|
||||
`unverifiable` is always absence — `missing_status`, `stale_status`,
|
||||
`restored_unconfirmed`, or a remote worker with no connection — and a null
|
||||
`agentWait` or an unchanged `worker-read` tail is that same absence seen again.
|
||||
Absence never authorizes stop, abandon, retry, or release: keep waiting, or
|
||||
inspect until you hold one of the positive signals above. A `nextAction` that
|
||||
names an inspecting command is asking for evidence, not for cleanup.
|
||||
|
||||
`worker-read --source auto` uses a proven provider transcript when available and
|
||||
otherwise returns bounded terminal output with a typed `fallbackReason`.
|
||||
Continue with its top-level cursor, which is pinned to that source. If Orca
|
||||
reports `source_changed`, restart without the old cursor. A bounded initial
|
||||
transcript tail can return an EOF cursor that follows only newly appended records;
|
||||
read `contentComplete`, `clipping`, and `warnings` before assuming omitted older
|
||||
records are pageable. Never guess a provider session ID, transcript path, or
|
||||
remote terminal handle.
|
||||
|
||||
## Was the mutation applied?
|
||||
|
||||
When a mutation's response was lost and named no Dispatch, do not replay blind.
|
||||
Every orchestration mutation accepts `--retry-request <id>`, which reuses one
|
||||
operation identity so Orca can replay, join, or recover it instead of starting a
|
||||
duplicate. Ask what happened first:
|
||||
|
||||
```text
|
||||
ORCA orchestration request-show --request <request_id> --json
|
||||
```
|
||||
|
||||
`completed` means the mutation already took effect; read its recorded receipt
|
||||
instead of rerunning. `pending` means the original mutation is still running or
|
||||
Orca restarted before recording its outcome; replay the original command with
|
||||
`--retry-request <request_id>`. `absent` means this runtime holds no receipt
|
||||
under your caller identity — that is not proof nothing happened, so inspect the
|
||||
affected Task, Dispatch, and terminal before deciding whether to retry.
|
||||
|
||||
When a worker's terminal accepted input but the submit is unconfirmed, use
|
||||
`terminal send --wait-submit <seconds>`: it observes the accepted prompt for that
|
||||
long and, on timeout, returns the input-accepted receipt without resending.
|
||||
|
||||
## Refused starts
|
||||
|
||||
`dispatch` and `worker-start` refuse the following preflight cases with a stable
|
||||
`error.code`; read it before choosing a recovery, and treat `error.data.nextSteps`
|
||||
as the exact recovery text. Older hosts may omit `data`, so treat every field as
|
||||
optional.
|
||||
|
||||
| Code | Meaning | Recovery |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `task_not_found` | No Task with that id, or not in the bound Run (`data.taskId`, `data.runId`) | Check `task-list --json`; create the Task with `task-create` if it does not exist |
|
||||
| `task_not_startable` | Task cannot start now: not `ready`, or invalid `--retry-of` (`data.status`, `data.unmetDependencies`, `data.retryOf`) | Wait for running dependencies with `check --wait`; retry or unblock failed ones; inspect `dispatch-show` if already dispatched |
|
||||
| `inject_rejected` | `--inject` refused because no recognized agent runs in the target (`data.terminal`, `data.reason`) | Start a recognized agent there or pick another terminal; or dispatch without `--inject` and use `terminal send` |
|
||||
| `runtime_error` | Any other failure, including a target terminal that already owns an active Dispatch | Read the message, inspect state, and do not retry unchanged |
|
||||
|
||||
## Retry, stop, and abandon
|
||||
|
||||
Retry only a positively proven failed or stopped attempt. Name the failed Task
|
||||
with `--task`, since `--spec` creates a new one. Placement is never silently
|
||||
inherited:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-start --task <task_id> --retry-of <dispatch_id> --worktree <explicit_placement> --agent <agent> --json
|
||||
```
|
||||
|
||||
After three consecutive failures for one Task, its dispatch context
|
||||
circuit-breaks and the Task is failed. Do not route around that boundary with a
|
||||
new Run or an unrelated Dispatch.
|
||||
|
||||
For `outcome_unknown`, inspect first, then make an explicit choice:
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-stop --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-abandon --dispatch <dispatch_id> --json
|
||||
```
|
||||
|
||||
`worker-stop` closes only the exact proven supervised agent terminal. It never
|
||||
deletes the worktree, setup terminal, configured tabs, or unrelated processes.
|
||||
`worker-abandon` fences orchestration while accepting that resources may remain
|
||||
live; it performs no remote, process, or filesystem action.
|
||||
|
||||
## Retain and release
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-retain --dispatch <dispatch_id> --json
|
||||
ORCA orchestration worker-release --dispatch <dispatch_id> --json
|
||||
```
|
||||
|
||||
Retain only when the user explicitly wants the settled terminal kept live.
|
||||
Release works after succeeded and failed reports, archives readable output, and
|
||||
closes only the exact terminal owned by that settled Dispatch. Replays may call
|
||||
release again safely. Reused, pre-existing, setup, coordinator, active,
|
||||
user-taken-over, and unproven terminals are retained.
|
||||
|
||||
A `worker-start` that failed before its agent was ready still owns the terminal
|
||||
it created. Its receipt names `worker-release`, and `worker-list` reports that
|
||||
row as `reclaimable`; release it there rather than closing the terminal by hand.
|
||||
|
||||
Never release because of timeout, TUI idle, heartbeat, status, question,
|
||||
escalation, or stale/rejected completion. If the receipt says `release_pending`
|
||||
or `release_unknown`, follow its exact recovery action. Never substitute
|
||||
`terminal close`.
|
||||
|
||||
`orchestration reset` is destructive recovery. Do not run it during active
|
||||
coordination unless the user explicitly abandons that state.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Worker contract
|
||||
|
||||
The injected preamble is authoritative. Copy its command rather than
|
||||
reconstructing flags. In particular, preserve the exact executable, worker
|
||||
handle, Dispatch capability, Task ID, and Dispatch ID.
|
||||
|
||||
## Heartbeat
|
||||
|
||||
Send heartbeats only at the cadence required by the live preamble. Skip them
|
||||
while blocked inside `ask` or `check --wait`; those calls are liveness signals.
|
||||
|
||||
```text
|
||||
ORCA orchestration send --from <worker_handle> --dispatch-capability <capability> --type heartbeat --subject "alive" --task-id <task_id> --dispatch-id <dispatch_id> --phase "<investigating|implementing|reviewing|waiting>"
|
||||
```
|
||||
|
||||
Use typed lifecycle flags, not a hand-written JSON payload. A heartbeat proves
|
||||
liveness, never completion.
|
||||
|
||||
## Ask and resume
|
||||
|
||||
Use Orca `ask` whenever the coordinator must answer. Never open a local question
|
||||
TUI the coordinator cannot answer.
|
||||
|
||||
```text
|
||||
ORCA orchestration ask --from <worker_handle> --dispatch-capability <capability> --question "<question>" --options "<choice-a>,<choice-b>" --timeout-ms 600000
|
||||
|
||||
ORCA orchestration ask --from <worker_handle> --dispatch-capability <capability> --resume <message_id> --timeout-ms 600000
|
||||
```
|
||||
|
||||
A timeout or disconnect leaves the original question pending. Resume its
|
||||
message ID; do not create a duplicate question.
|
||||
|
||||
## Reading coordinator follow-ups
|
||||
|
||||
The coordinator steers a running worker with `send --to dispatch:<id>`. That
|
||||
enqueue is durable but does not interrupt you, so nothing arrives unless you
|
||||
look:
|
||||
|
||||
```text
|
||||
ORCA orchestration check --terminal <worker_handle> --json
|
||||
```
|
||||
|
||||
Run it at each natural checkpoint — before starting a new file, after a test
|
||||
run — and once more immediately before `worker_done`, so a redirect or a
|
||||
cancellation lands before the Task settles. `check` names its caller with
|
||||
`--terminal`, never `--from`. Stop checking after `worker_done`.
|
||||
|
||||
If `check` returns `consumer_fenced`, this process no longer owns its Dispatch:
|
||||
the Attempt was re-attached to another worker or settled without you. Stop, do
|
||||
not send `worker_done`, and do not retry the check. An empty `check` never means
|
||||
you were replaced; `consumer_fenced` is the only way you learn that.
|
||||
|
||||
## Escalation
|
||||
|
||||
Escalate only before completion and only when the coordinator must intervene:
|
||||
|
||||
```text
|
||||
ORCA orchestration send --from <worker_handle> --dispatch-capability <capability> --type escalation --subject "Blocked: <reason>" --body "<details>" --task-id <task_id> --dispatch-id <dispatch_id>
|
||||
```
|
||||
|
||||
## Completion
|
||||
|
||||
Send exactly one terminal report. `--body` is three sentences: what changed,
|
||||
what was found, and what remains. Use `--outcome failed` when the requested work
|
||||
is not complete; never hide failure in prose or silently exit.
|
||||
|
||||
Append `--files-modified` or `--report-path` only when applicable, using actual
|
||||
paths. Do not send documentation placeholders as metadata.
|
||||
|
||||
```text
|
||||
ORCA orchestration send --from <worker_handle> --dispatch-capability <capability> --type worker_done --subject "<short status>" --body "<three sentences: work, findings, remaining>" --task-id <task_id> --dispatch-id <dispatch_id> --outcome succeeded
|
||||
```
|
||||
|
||||
After `worker_done`, end the dispatched turn and idle. Do not poll, close your
|
||||
own terminal, or begin unrelated work. A later direct user instruction is new
|
||||
user-owned work and must not reuse settled lifecycle IDs; a supervised follow-up
|
||||
arrives with a fresh preamble and Task block.
|
||||
@@ -32,16 +32,19 @@ same way in POSIX shells, PowerShell, and cmd.exe.
|
||||
If the selected executable cannot run, report its exact error and stop. Do not fall through
|
||||
to another executable, which could silently target a different Orca build.
|
||||
|
||||
## Load the full guide before running Orca commands
|
||||
## Load the version-matched guide before running Orca commands
|
||||
|
||||
```text
|
||||
ORCA skills get orchestration
|
||||
```
|
||||
|
||||
That prints the complete, version-matched guide for the exact binary that will handle your
|
||||
next commands — task creation and dispatch, injected lifecycle preambles, worker_done
|
||||
authority, decision gates, and coordinator loops. Read it first, then run the specific
|
||||
command you need.
|
||||
That prints the compact, version-matched guide for the exact binary that will handle your
|
||||
next commands. It covers the normal local coordinator loop. For a conditional action gate
|
||||
such as remote placement, uncertain release recovery, or expanded DAG work, load only the
|
||||
reference that gate names with
|
||||
`ORCA skills get orchestration --reference references/<file>.md`
|
||||
(`--references` lists the names). If that binary rejects `--reference`, run
|
||||
`ORCA skills get orchestration --full` and read the named bundled reference before acting.
|
||||
|
||||
Don't guess subcommands or flags from memory or from a cached copy of this stub. They
|
||||
change between Orca releases, and this file deliberately no longer lists them. Confirm the
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
---
|
||||
name: orchestration
|
||||
description: >-
|
||||
Use Orca orchestration for structured multi-agent coordination: threaded
|
||||
messages, blocking ask/reply flows, task dispatch, worker_done/escalation
|
||||
waits, task DAGs, decision gates, or coordinator loops. Use `orca-cli`
|
||||
instead for full ownership handoffs, including requests phrased as "hand
|
||||
off", "handoff", "handover", "give this to another agent", or "another
|
||||
worktree" when the user did not explicitly ask to supervise, monitor, wait
|
||||
for results, or coordinate a DAG. Use `orca-cli` for terminal control,
|
||||
lightweight terminal prompts, shell commands, Orca worktree management,
|
||||
reading or waiting on terminals, and the Orca embedded browser. Use Computer
|
||||
Use for external browser windows, webviews, Orca app UI, or desktop UI
|
||||
outside Orca's embedded browser only when the task requires OS/window-level
|
||||
control such as focus, menus, dialogs, coordinates, or screenshots. Use
|
||||
`orca-cli` for Orca's embedded pages and a page-automation tool such as
|
||||
Playwright or CDP for external pages.
|
||||
Coordinate supervised Orca workers: threaded messages, blocking ask/reply,
|
||||
task dispatch, worker_done/escalation waits, task DAGs, decision gates,
|
||||
coordinator loops, and decomposing work across agents. Use `orca-cli` for full
|
||||
ownership handoffs — "hand off", "handoff", "handover", "give this to another
|
||||
agent", "another worktree" — unless asked to supervise, monitor, or coordinate
|
||||
a DAG, and for terminal control, lightweight terminal prompts, shell commands,
|
||||
Orca worktree management, and reading or waiting on terminals. Use Computer
|
||||
Use for external browser windows, webviews, Orca app UI, or desktop UI outside
|
||||
Orca's embedded browser only when the task requires OS/window-level control
|
||||
such as focus, menus, dialogs, coordinates, or screenshots. Use `orca-cli` for
|
||||
Orca's embedded pages and a page-automation tool such as Playwright or CDP for
|
||||
external pages.
|
||||
---
|
||||
|
||||
# Orca Orchestration
|
||||
@@ -51,16 +49,19 @@ same way in POSIX shells, PowerShell, and cmd.exe.
|
||||
If the selected executable cannot run, report its exact error and stop. Do not fall through
|
||||
to another executable, which could silently target a different Orca build.
|
||||
|
||||
## Load the full guide before running Orca commands
|
||||
## Load the version-matched guide before running Orca commands
|
||||
|
||||
```text
|
||||
ORCA skills get orchestration
|
||||
```
|
||||
|
||||
That prints the complete, version-matched guide for the exact binary that will handle your
|
||||
next commands — task creation and dispatch, injected lifecycle preambles, worker_done
|
||||
authority, decision gates, and coordinator loops. Read it first, then run the specific
|
||||
command you need.
|
||||
That prints the compact, version-matched guide for the exact binary that will handle your
|
||||
next commands. It covers the normal local coordinator loop. For a conditional action gate
|
||||
such as remote placement, uncertain release recovery, or expanded DAG work, load only the
|
||||
reference that gate names with
|
||||
`ORCA skills get orchestration --reference references/<file>.md`
|
||||
(`--references` lists the names). If that binary rejects `--reference`, run
|
||||
`ORCA skills get orchestration --full` and read the named bundled reference before acting.
|
||||
|
||||
Don't guess subcommands or flags from memory or from a cached copy of this stub. They
|
||||
change between Orca releases, and this file deliberately no longer lists them. Confirm the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CommandSpec } from './args'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
import {
|
||||
REPEATED_FLAG_SEPARATOR,
|
||||
findCommandSpec,
|
||||
@@ -325,6 +326,25 @@ describe('validateCommandAndFlags', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('points --from at --terminal on the one verb that renamed the caller flag', () => {
|
||||
const parsed = parseArgs(['orchestration', 'check', '--from', 'term_a'])
|
||||
|
||||
try {
|
||||
validateCommandAndFlags(COMMAND_SPECS, parsed)
|
||||
throw new Error('expected validateCommandAndFlags to throw')
|
||||
} catch (error) {
|
||||
const data = (error as { data?: { suggestions: string[]; nextSteps: string[] } }).data
|
||||
expect(data?.suggestions[0]).toBe('terminal')
|
||||
expect(data?.nextSteps[0]).toContain('--terminal')
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves --from alone where the command actually accepts it', () => {
|
||||
const parsed = parseArgs(['orchestration', 'reply', '--from', 'term_a'])
|
||||
|
||||
expect(() => validateCommandAndFlags(COMMAND_SPECS, parsed)).not.toThrow()
|
||||
})
|
||||
|
||||
it('attaches did-you-mean suggestions to unknown-command errors', () => {
|
||||
const suggestSpecs: CommandSpec[] = [
|
||||
{
|
||||
|
||||
File diff suppressed because one or more lines are too long
+71
-3
@@ -4,15 +4,45 @@ import {
|
||||
stripAutomationOwnerConflictCode
|
||||
} from '../shared/automation-owner-conflict'
|
||||
import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
|
||||
import { worktreeSelectorRecovery } from './worktree-selector-recovery'
|
||||
import type { RuntimeRpcFailure } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
|
||||
|
||||
type CliErrorContext = {
|
||||
export type CliErrorContext = {
|
||||
commandPath?: readonly string[]
|
||||
/** The `--worktree` value this invocation sent; the runtime's error never echoes it. */
|
||||
worktreeSelector?: string
|
||||
}
|
||||
|
||||
function selectorRecovery(code: string | undefined, context: CliErrorContext) {
|
||||
return code === 'selector_not_found' && context.worktreeSelector
|
||||
? worktreeSelectorRecovery(context.worktreeSelector)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function errorData(error: unknown): unknown {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return error.response.error.data
|
||||
}
|
||||
return error instanceof RuntimeClientError ? error.data : undefined
|
||||
}
|
||||
|
||||
function errorCode(error: unknown): string | undefined {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return error.response.error.code
|
||||
}
|
||||
return error instanceof RuntimeClientError ? error.code : undefined
|
||||
}
|
||||
|
||||
export function formatCliError(error: unknown, context: CliErrorContext = {}): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const selector = selectorRecovery(errorCode(error), context)
|
||||
if (selector) {
|
||||
return formatMessageWithNextSteps(
|
||||
message,
|
||||
nextStepsFromData(mergeSelectorRecovery(errorData(error), selector))
|
||||
)
|
||||
}
|
||||
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
|
||||
if (hasOrchestrationRequestId(error.data)) {
|
||||
return message
|
||||
@@ -58,9 +88,25 @@ function hasOrchestrationRequestId(data: unknown): boolean {
|
||||
}
|
||||
|
||||
export function reportCliError(error: unknown, json: boolean, context: CliErrorContext = {}): void {
|
||||
const selector = selectorRecovery(errorCode(error), context)
|
||||
if (json) {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
console.log(JSON.stringify(withAutomationOwnerConflictRecovery(error.response), null, 2))
|
||||
const response = withAutomationOwnerConflictRecovery(error.response)
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
selector
|
||||
? {
|
||||
...response,
|
||||
error: {
|
||||
...response.error,
|
||||
data: mergeSelectorRecovery(response.error.data, selector)
|
||||
}
|
||||
}
|
||||
: response,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const response: RuntimeRpcFailure = {
|
||||
id: 'local',
|
||||
@@ -111,6 +157,24 @@ function formatMessageWithNextSteps(message: string, nextSteps: readonly string[
|
||||
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
|
||||
}
|
||||
|
||||
/** Why merge: a mutation error already carries its request id, and `??` dropped the selector grammar. */
|
||||
function mergeSelectorRecovery(
|
||||
data: unknown,
|
||||
selector: ReturnType<typeof selectorRecovery>
|
||||
): unknown {
|
||||
if (!selector) {
|
||||
return data
|
||||
}
|
||||
if (data === null || typeof data !== 'object') {
|
||||
return selector
|
||||
}
|
||||
return {
|
||||
...selector,
|
||||
...data,
|
||||
nextSteps: [...selector.nextSteps, ...nextStepsFromData(data)]
|
||||
}
|
||||
}
|
||||
|
||||
function nextStepsFromData(data: unknown): string[] {
|
||||
if (
|
||||
data &&
|
||||
@@ -125,9 +189,13 @@ function nextStepsFromData(data: unknown): string[] {
|
||||
}
|
||||
|
||||
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
|
||||
const selector = selectorRecovery(errorCode(error), context)
|
||||
// Why: error-specific recovery must win over the generic computer fallback.
|
||||
if (error instanceof RuntimeClientError && error.data !== undefined) {
|
||||
return error.data
|
||||
return mergeSelectorRecovery(error.data, selector)
|
||||
}
|
||||
if (selector) {
|
||||
return selector
|
||||
}
|
||||
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
|
||||
if (conflict) {
|
||||
|
||||
@@ -110,14 +110,24 @@ export type FlagErrorData = {
|
||||
nextSteps: string[]
|
||||
}
|
||||
|
||||
// Why: edit distance cannot recover a rename. `orchestration check` is the one verb
|
||||
// that identifies its caller with `--terminal` while every sibling uses `--from`, so
|
||||
// the near-miss ranking answered `--json`/`--run` and left the caller stuck (#16904).
|
||||
// A synonym only fires where the typed flag is rejected and its partner is accepted.
|
||||
const FLAG_SYNONYMS: Readonly<Record<string, string>> = { from: 'terminal' }
|
||||
|
||||
function suggestFlags(flag: string, validFlags: string[]): string[] {
|
||||
const synonym = FLAG_SYNONYMS[flag]
|
||||
const scored: { label: string; distance: number }[] = []
|
||||
for (const candidate of validFlags) {
|
||||
if (Math.abs(flag.length - candidate.length) <= SUGGESTION_THRESHOLD) {
|
||||
scored.push({ label: candidate, distance: levenshtein(flag, candidate) })
|
||||
}
|
||||
}
|
||||
return rankByDistance(scored)
|
||||
const ranked = rankByDistance(scored)
|
||||
return synonym && validFlags.includes(synonym)
|
||||
? [synonym, ...ranked.filter((name) => name !== synonym)].slice(0, MAX_SUGGESTIONS)
|
||||
: ranked
|
||||
}
|
||||
|
||||
// Why: include the accepted set so agents can recover without another help call.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describeQuoteStrippedJsonFlag } from './quote-stripped-json-flag'
|
||||
|
||||
export function getRequiredStringFlag(flags: Map<string, string | boolean>, name: string): string {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
if (typeof value === 'string' && value.length > 0) {
|
||||
return value
|
||||
}
|
||||
@@ -15,6 +16,7 @@ export function getRequiredStringFlagAllowingEmpty(
|
||||
name: string
|
||||
): string {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
@@ -26,9 +28,24 @@ export function getOptionalStringFlag(
|
||||
name: string
|
||||
): string | undefined {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A valued flag whose value the shell (or a missing variable) ate parses as `true`. Dropping it
|
||||
* silently mints a fresh mutation identity and can deliver a prompt twice (#15180), so every
|
||||
* valued-flag accessor refuses the damaged shape by name.
|
||||
*/
|
||||
export function rejectValuelessFlag(value: string | boolean | undefined, name: string): void {
|
||||
if (value === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`--${name} requires a value; it was passed with none.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON-valued flag, rejected up front when a native argv boundary stripped its quotes so the
|
||||
* error names the shell instead of the user's value (#16706). The value itself is still parsed
|
||||
@@ -64,6 +81,7 @@ export function getOptionalNumberFlag(
|
||||
name: string
|
||||
): number | undefined {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
if (typeof value !== 'string' || value.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
@@ -131,6 +149,7 @@ export function getOptionalNullableNumberFlag(
|
||||
name: string
|
||||
): number | null | undefined {
|
||||
const value = flags.get(name)
|
||||
rejectValuelessFlag(value, name)
|
||||
if (value === 'null') {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,8 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { formatCliError } from './format'
|
||||
import { formatCliError, reportCliError } from './format'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
|
||||
function selectorNotFound(): RuntimeRpcFailureError {
|
||||
return new RuntimeRpcFailureError({
|
||||
id: 'req_selector',
|
||||
ok: false,
|
||||
error: { code: 'selector_not_found', message: 'selector_not_found' },
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
})
|
||||
}
|
||||
|
||||
describe('worktree selector recovery', () => {
|
||||
it('names the offending value and the valid forms on a bare repo id', () => {
|
||||
const output = formatCliError(selectorNotFound(), {
|
||||
commandPath: ['orchestration', 'worker-start'],
|
||||
worktreeSelector: 'id:github:stablyai/orca'
|
||||
})
|
||||
|
||||
expect(output).toContain('No Orca workspace matched the worktree selector')
|
||||
expect(output).toContain('id:github:stablyai/orca')
|
||||
expect(output).toContain('Did you mean: id:github:stablyai/orca::<absolute-path>')
|
||||
expect(output).toContain('Valid selector forms:')
|
||||
expect(output).toContain('a bare repository id is not a worktree id')
|
||||
})
|
||||
|
||||
it('carries the same recovery into the --json failure envelope', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
reportCliError(selectorNotFound(), true, {
|
||||
commandPath: ['terminal', 'create'],
|
||||
worktreeSelector: 'path:/nope'
|
||||
})
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
|
||||
error: {
|
||||
code: 'selector_not_found',
|
||||
data: { selector: 'path:/nope', validSelectorForms: expect.arrayContaining(['current']) }
|
||||
}
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps the selector grammar when the error already carries mutation recovery data', () => {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
reportCliError(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'req_selector',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'selector_not_found',
|
||||
message: 'selector_not_found',
|
||||
// The mutation-recovery layer already attached its request id.
|
||||
data: { orchestrationRequestId: 'req_abc', nextSteps: ['Run request-show first.'] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
}),
|
||||
true,
|
||||
{ commandPath: ['orchestration', 'worker-start'], worktreeSelector: 'bare-repo-id' }
|
||||
)
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0]))).toMatchObject({
|
||||
error: {
|
||||
data: {
|
||||
orchestrationRequestId: 'req_abc',
|
||||
selector: 'bare-repo-id',
|
||||
validSelectorForms: expect.arrayContaining(['current']),
|
||||
nextSteps: expect.arrayContaining(['Run request-show first.'])
|
||||
}
|
||||
}
|
||||
})
|
||||
log.mockRestore()
|
||||
})
|
||||
|
||||
it('keeps both recoveries in the text message for a local selector error', () => {
|
||||
const output = formatCliError(
|
||||
new RuntimeClientError('selector_not_found', 'selector_not_found', {
|
||||
orchestrationRequestId: 'req_abc',
|
||||
nextSteps: ['Run request-show first.']
|
||||
}),
|
||||
{ worktreeSelector: 'bare-repo-id' }
|
||||
)
|
||||
|
||||
expect(output).toContain('Valid selector forms:')
|
||||
expect(output).toContain('Run request-show first.')
|
||||
})
|
||||
|
||||
it('stays silent when no worktree selector was passed', () => {
|
||||
expect(formatCliError(selectorNotFound(), { commandPath: ['worktree', 'show'] })).toBe(
|
||||
'selector_not_found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('CLI error recovery', () => {
|
||||
it('prints did-you-mean next steps for an unknown-command error carrying data', () => {
|
||||
const error = new RuntimeClientError('invalid_argument', 'Unknown command: worktree remov', {
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@ import type { CliStatusResult } from '../shared/runtime-types'
|
||||
import { prepareComputerCliJsonResult } from './computer-format'
|
||||
import type { RuntimeRpcSuccess } from './runtime-client'
|
||||
|
||||
export { formatCliError, reportCliError } from './cli-error'
|
||||
export { formatCliError, reportCliError, type CliErrorContext } from './cli-error'
|
||||
|
||||
export {
|
||||
formatBrowserProfileList,
|
||||
@@ -40,7 +40,8 @@ export {
|
||||
formatTerminalSend,
|
||||
formatTerminalShow,
|
||||
formatTerminalSplit,
|
||||
formatTerminalWait
|
||||
formatTerminalWait,
|
||||
terminalSendWarnings
|
||||
} from './terminal-format'
|
||||
export {
|
||||
formatAutomationList,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
|
||||
export type BundledSkillGuideReference = {
|
||||
name: string
|
||||
markdown: string
|
||||
}
|
||||
|
||||
export type BundledSkillGuide = {
|
||||
name: string
|
||||
description: string
|
||||
markdown: string
|
||||
fullMarkdown: string
|
||||
aliases: readonly string[]
|
||||
references: readonly BundledSkillGuideReference[]
|
||||
}
|
||||
|
||||
function canonicalGuides(guides: readonly BundledSkillGuide[]): BundledSkillGuide[] {
|
||||
return [...guides].sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the embedded guide table in canonical order. Deferred because the table is
|
||||
* large and unrelated CLI commands must not pay its module-load cost at startup.
|
||||
*/
|
||||
export async function loadCanonicalGuides(): Promise<BundledSkillGuide[]> {
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
return canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
}
|
||||
|
||||
export function requireTopic(
|
||||
flags: Map<string, string | boolean>,
|
||||
guides: BundledSkillGuide[]
|
||||
): BundledSkillGuide {
|
||||
const availableTopics = guides.map((guide) => guide.name).join(', ')
|
||||
const topic = flags.get('topic')
|
||||
if (typeof topic !== 'string' || topic.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Missing skill topic. Available topics: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
// Why: installed stubs may retain an old topic forever, so aliases and canonical
|
||||
// names share one lookup table instead of being treated as transient CLI aliases.
|
||||
const guideByTopic = new Map<string, BundledSkillGuide>(
|
||||
guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide]))
|
||||
)
|
||||
const guide = guideByTopic.get(topic)
|
||||
if (!guide) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown skill topic "${topic}". Available topics: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
return guide
|
||||
}
|
||||
@@ -5,7 +5,8 @@ const getTerminalHandleMock = vi.hoisted(() => vi.fn())
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
const originalPaneKey = process.env.ORCA_PANE_KEY
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
const printResultMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('../format', () => ({ printResult: printResultMock }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
@@ -13,6 +14,7 @@ import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
describe('orchestration check identity', () => {
|
||||
beforeEach(() => {
|
||||
callMock.mockReset().mockResolvedValue({ result: { messages: [], count: 0 } })
|
||||
printResultMock.mockReset()
|
||||
getTerminalHandleMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
@@ -31,12 +33,12 @@ describe('orchestration check identity', () => {
|
||||
}
|
||||
})
|
||||
|
||||
const invokeCheck = (flags: Map<string, string | boolean>) =>
|
||||
const invokeCheck = (flags: Map<string, string | boolean>, json = true) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration check']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
json
|
||||
} as never)
|
||||
|
||||
it('carries the caller pane key when the environment handle may be stale', async () => {
|
||||
@@ -91,4 +93,20 @@ describe('orchestration check identity', () => {
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it.each([true, false])(
|
||||
'surfaces a stale --terminal refusal instead of an empty inbox (json=%s)',
|
||||
async (json) => {
|
||||
callMock.mockRejectedValue(
|
||||
Object.assign(new Error('Terminal term_gone has no live pane bound to a Run'), {
|
||||
code: 'stable_pane_required'
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
invokeCheck(new Map<string, string | boolean>([['terminal', 'term_gone']]), json)
|
||||
).rejects.toMatchObject({ code: 'stable_pane_required' })
|
||||
expect(printResultMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -164,6 +164,42 @@ it('normalizes compatibility-read failures to operation_unknown', async () => {
|
||||
).rejects.toMatchObject({ code: 'operation_unknown' })
|
||||
})
|
||||
|
||||
it('preserves the worker_done mutation identity when post-verification fails', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
message: { id: 'msg_unconfirmed', run_id: 'run_1' },
|
||||
mutation: { requestId: 'mutation_worker_done', replayed: false }
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
result: { dispatch: { id: 'ctx_1', status: 'dispatched' } }
|
||||
})
|
||||
.mockResolvedValueOnce({ result: { tasks: [] } })
|
||||
|
||||
await expect(
|
||||
ORCHESTRATION_HANDLERS['orchestration send']({
|
||||
flags: new Map([
|
||||
['from', 'term_worker'],
|
||||
['subject', 'done'],
|
||||
['type', 'worker_done'],
|
||||
['task-id', 'task_1'],
|
||||
['dispatch-id', 'ctx_1'],
|
||||
['outcome', 'succeeded']
|
||||
]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
).rejects.toMatchObject({
|
||||
code: 'operation_unknown',
|
||||
data: { orchestrationRequestId: 'mutation_worker_done' },
|
||||
message: expect.stringMatching(
|
||||
/Do not send a new completion.*--retry-request mutation_worker_done/s
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a legacy response only after the authoritative dispatch is terminal', async () => {
|
||||
callMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('extracted orchestration worker formatting', () => {
|
||||
expect(
|
||||
formatWorkerRead({
|
||||
source: 'transcript',
|
||||
provider: 'codex',
|
||||
transcript: {
|
||||
messages: [
|
||||
{
|
||||
@@ -74,11 +75,20 @@ describe('extracted orchestration worker formatting', () => {
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
nextCursor: 'owr1_next',
|
||||
limited: false,
|
||||
returnedMessageCount: 1
|
||||
},
|
||||
cursor: 'owr1_next',
|
||||
fallbackReason: null,
|
||||
warnings: []
|
||||
} as never)
|
||||
).toBe(
|
||||
'[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png'
|
||||
'Source: transcript (provider=codex)\n' +
|
||||
'Archived: false\n' +
|
||||
'Continuation cursor (opaque; pass unchanged to --cursor): owr1_next\n\n' +
|
||||
'[assistant] working\n[tool inspect] [unserializable input]\n[tool result error] failed\n[image] https://example.test/proof.png'
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
|
||||
// Why: isolate the handler's flag-to-param mapping; printResult only writes output.
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import { printResult } from '../format'
|
||||
|
||||
async function runTaskListBrief(): Promise<{
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}> {
|
||||
vi.mocked(printResult).mockClear()
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
return vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
}
|
||||
|
||||
describe('orchestration task-list brief output', () => {
|
||||
it('requests server-side brief and falls back client-side for older runtimes', async () => {
|
||||
callMock.mockReset().mockResolvedValue({
|
||||
result: {
|
||||
// No spec_truncated field — the pre-brief-runtime signature.
|
||||
tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
|
||||
const response = await runTaskListBrief()
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.taskList',
|
||||
expect.objectContaining({ brief: true })
|
||||
)
|
||||
expect(response.result.tasks[0].spec).toHaveLength(160)
|
||||
expect(response.result.tasks[0].spec_truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes server-abbreviated rows through untouched', async () => {
|
||||
const serverTasks = [
|
||||
{ id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true }
|
||||
]
|
||||
callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } })
|
||||
|
||||
const response = await runTaskListBrief()
|
||||
|
||||
// Why: re-abbreviating a server-truncated spec would flip spec_truncated
|
||||
// back to false (the truncated text fits the cap).
|
||||
expect(response.result.tasks).toBe(serverTasks)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const originalExitCode = process.exitCode
|
||||
const originalCliCommand = process.env.ORCA_CLI_COMMAND
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
@@ -21,6 +23,17 @@ describe('orchestration timeout flag validation', () => {
|
||||
callMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
delete process.env.ORCA_PANE_KEY
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode
|
||||
if (originalCliCommand === undefined) {
|
||||
delete process.env.ORCA_CLI_COMMAND
|
||||
} else {
|
||||
process.env.ORCA_CLI_COMMAND = originalCliCommand
|
||||
}
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const invokeCheck = (flags: Map<string, string | boolean>) =>
|
||||
@@ -39,6 +52,14 @@ describe('orchestration timeout flag validation', () => {
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
const invokePlainAsk = (flags: Map<string, string | boolean>) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration ask']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
it.each(invalidTimeoutValues)('rejects invalid check --timeout-ms: %s', async (_label, value) => {
|
||||
await expect(
|
||||
invokeCheck(
|
||||
@@ -221,6 +242,37 @@ describe('orchestration timeout flag validation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prints the pending message ID and exact capability-bound resume command on timeout', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
process.env.ORCA_CLI_COMMAND = 'orca-dev'
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
answer: null,
|
||||
messageId: 'msg_question',
|
||||
threadId: 'thread_question',
|
||||
timedOut: true,
|
||||
timeoutMs: 30_000
|
||||
}
|
||||
})
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await invokePlainAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['question', 'Proceed?'],
|
||||
['dispatch-capability', 'dcap_secret'],
|
||||
['timeout-ms', '30000']
|
||||
])
|
||||
)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'ask timeout after 30000ms; question is still pending (messageId: msg_question). ' +
|
||||
'Resume waiting; do not ask again:\n' +
|
||||
'orca-dev orchestration ask --from term_worker --dispatch-capability dcap_secret ' +
|
||||
'--resume msg_question --timeout-ms 30000'
|
||||
)
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('rejects ambiguous ask create/resume input before RPC', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
await expect(
|
||||
|
||||
@@ -2,12 +2,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const callMock = vi.fn()
|
||||
const originalExitCode = process.exitCode
|
||||
const originalCliCommand = process.env.ORCA_CLI_COMMAND
|
||||
|
||||
type RecoveryWorkerStartResult = {
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
state: string
|
||||
effects: unknown[]
|
||||
residualResources: unknown[]
|
||||
nextCommands: string[]
|
||||
}
|
||||
|
||||
vi.mock('../format', () => ({ printResult: vi.fn() }))
|
||||
vi.mock('../selectors', () => ({ getTerminalHandle: vi.fn() }))
|
||||
|
||||
import { ORCHESTRATION_HANDLERS } from './orchestration'
|
||||
import { printResult } from '../format'
|
||||
import { BOOLEAN_FLAGS, parseArgs } from '../args'
|
||||
import { formatCommandHelp } from '../help'
|
||||
import { ORCHESTRATION_WORKER_COMMAND_SPECS } from '../specs/orchestration-worker-specs'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
|
||||
describe('orchestration worker-start CLI contract', () => {
|
||||
@@ -15,18 +28,24 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
callMock.mockReset()
|
||||
vi.mocked(printResult).mockReset()
|
||||
process.exitCode = undefined
|
||||
delete process.env.ORCA_CLI_COMMAND
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
process.exitCode = originalExitCode
|
||||
if (originalCliCommand === undefined) {
|
||||
delete process.env.ORCA_CLI_COMMAND
|
||||
} else {
|
||||
process.env.ORCA_CLI_COMMAND = originalCliCommand
|
||||
}
|
||||
})
|
||||
|
||||
const invokeWorkerStart = (flags: Map<string, string | boolean>) =>
|
||||
const invokeWorkerStart = (flags: Map<string, string | boolean>, json = true) =>
|
||||
ORCHESTRATION_HANDLERS['orchestration worker-start']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
json
|
||||
} as never)
|
||||
|
||||
it('passes the complete supported creation contract and retry receipt', async () => {
|
||||
@@ -56,7 +75,7 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
['timeout-ms', '90000'],
|
||||
['run', 'run_1'],
|
||||
['from', 'term_coord'],
|
||||
['retry-request', 'request_1']
|
||||
['retry-request', '44444444-4444-4444-8444-444444444444']
|
||||
])
|
||||
)
|
||||
|
||||
@@ -80,7 +99,7 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
from: 'term_coord',
|
||||
devMode: false
|
||||
},
|
||||
{ orchestrationRequestId: 'request_1' }
|
||||
{ orchestrationRequestId: '44444444-4444-4444-8444-444444444444' }
|
||||
)
|
||||
expect(process.exitCode).toBeUndefined()
|
||||
})
|
||||
@@ -125,6 +144,23 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('forwards --spec without a task for atomic creation', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: { runId: 'run_1', taskId: 'task_new', dispatchId: 'ctx_1', state: 'ready' }
|
||||
})
|
||||
await invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['spec', 'Implement atomic start'],
|
||||
['agent', 'codex'],
|
||||
['from', 'term_coord']
|
||||
])
|
||||
)
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.workerStart',
|
||||
expect.objectContaining({ task: undefined, spec: 'Implement atomic start' })
|
||||
)
|
||||
})
|
||||
|
||||
it('fails before worker-start when the runtime would strip launch preferences', async () => {
|
||||
callMock.mockResolvedValueOnce({ result: { capabilities: [] } })
|
||||
|
||||
@@ -164,6 +200,53 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
expect(process.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['JSON', 'orca-dev', true],
|
||||
['plain', 'orca-ide', false]
|
||||
] as const)(
|
||||
'renders %s recovery commands through the resolved %s executable',
|
||||
async (_format, executable, json) => {
|
||||
process.env.ORCA_CLI_COMMAND = executable
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_unknown',
|
||||
state: 'outcome_unknown',
|
||||
effects: [],
|
||||
residualResources: [],
|
||||
nextCommands: [
|
||||
'orca orchestration worker-show --dispatch ctx_unknown --json',
|
||||
'orca orchestration worker-abandon --dispatch ctx_unknown --json'
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
await invokeWorkerStart(
|
||||
new Map<string, string | boolean>([
|
||||
['task', 'task_1'],
|
||||
['agent', 'codex'],
|
||||
['from', 'term_coord']
|
||||
]),
|
||||
json
|
||||
)
|
||||
|
||||
const [response, , formatter] = vi.mocked(printResult).mock.calls[0] as [
|
||||
{ result: RecoveryWorkerStartResult },
|
||||
boolean,
|
||||
(result: RecoveryWorkerStartResult) => string
|
||||
]
|
||||
expect(response.result.nextCommands).toEqual([
|
||||
`${executable} orchestration worker-show --dispatch ctx_unknown --json`,
|
||||
`${executable} orchestration worker-abandon --dispatch ctx_unknown --json`
|
||||
])
|
||||
if (!json) {
|
||||
expect(formatter(response.result)).toContain(
|
||||
`Next command: ${executable} orchestration worker-show --dispatch ctx_unknown --json`
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('prints the Structured Chat recovery action for a refused worker start', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
@@ -342,4 +425,327 @@ describe('orchestration worker-start CLI contract', () => {
|
||||
source: 'transcript'
|
||||
})
|
||||
})
|
||||
|
||||
it('formats a legacy worker-list response without projection or page fields', async () => {
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
workers: [
|
||||
{
|
||||
dispatchId: 'ctx_legacy',
|
||||
taskId: 'task_legacy',
|
||||
runId: 'run_legacy',
|
||||
workerState: 'ready',
|
||||
dispatchStatus: 'dispatched',
|
||||
agentTerminalHandle: 'term_legacy',
|
||||
terminalState: 'active',
|
||||
resource: null
|
||||
}
|
||||
],
|
||||
counts: { active: 1 }
|
||||
}
|
||||
})
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map(),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
|
||||
| ((result: { workers: unknown[]; counts: Record<string, number> }) => string)
|
||||
| undefined
|
||||
expect(
|
||||
formatter?.({
|
||||
workers: [
|
||||
{
|
||||
dispatchId: 'ctx_legacy',
|
||||
taskId: 'task_legacy',
|
||||
runId: 'run_legacy',
|
||||
workerState: 'ready',
|
||||
dispatchStatus: 'dispatched',
|
||||
agentTerminalHandle: 'term_legacy',
|
||||
terminalState: 'active',
|
||||
resource: null
|
||||
}
|
||||
],
|
||||
counts: { active: 1 }
|
||||
})
|
||||
).toContain('ctx_legacy task=task_legacy [ready] terminal=active')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['--include-remote', new Map<string, string | boolean>([['include-remote', true]])],
|
||||
['--limit', new Map<string, string | boolean>([['limit', '10']])],
|
||||
['--cursor', new Map<string, string | boolean>([['cursor', 'legacy_cursor']])]
|
||||
])('fails closed when an older runtime strips explicit %s semantics', async (_flag, flags) => {
|
||||
callMock.mockResolvedValue({ result: { workers: [], counts: {} } })
|
||||
|
||||
await expect(
|
||||
ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags,
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
).rejects.toMatchObject({ code: 'incompatible_runtime' })
|
||||
|
||||
// The extra call is the bound-Run lookup; the enumeration itself must not be retried.
|
||||
expect(
|
||||
callMock.mock.calls.filter(([method]) => method === 'orchestration.workerList')
|
||||
).toHaveLength(1)
|
||||
expect(printResult).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prints each projected row with its literal next-action argv', async () => {
|
||||
const response = {
|
||||
result: {
|
||||
workers: [
|
||||
{
|
||||
dispatchId: 'ctx_live',
|
||||
taskId: 'task_live',
|
||||
runId: 'run_1',
|
||||
workerState: 'running',
|
||||
dispatchStatus: 'dispatched',
|
||||
agentTerminalHandle: 'term_live',
|
||||
terminalState: 'active',
|
||||
resource: null,
|
||||
projection: {
|
||||
provider: { id: 'claude', model: 'opus' },
|
||||
host: { id: 'local' },
|
||||
workspace: { id: 'ws_1' },
|
||||
stage: { activity: 'working' },
|
||||
liveness: { verdict: 'live' },
|
||||
nextAction: {
|
||||
argv: ['orchestration', 'worker-release', '--dispatch', 'ctx_live']
|
||||
},
|
||||
attention: { categories: ['settled'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
dispatchId: 'ctx_done',
|
||||
taskId: 'task_done',
|
||||
runId: 'run_1',
|
||||
workerState: 'released',
|
||||
dispatchStatus: 'completed',
|
||||
agentTerminalHandle: null,
|
||||
terminalState: null,
|
||||
resource: null,
|
||||
projection: {
|
||||
provider: null,
|
||||
host: { id: 'local' },
|
||||
workspace: null,
|
||||
stage: { activity: 'released' },
|
||||
liveness: { verdict: 'exited' },
|
||||
nextAction: { argv: [] },
|
||||
attention: { categories: [] }
|
||||
}
|
||||
}
|
||||
],
|
||||
counts: { active: 1 },
|
||||
page: { total: 2, hasMore: false, nextCursor: null }
|
||||
}
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>(),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
|
||||
| ((result: (typeof response)['result']) => string)
|
||||
| undefined
|
||||
const output = formatter?.(response.result)
|
||||
expect(output).toContain(
|
||||
'ctx_live task=task_live [running/working] attention=settled liveness=live provider=claude/opus host=local workspace=ws_1 terminal=active next=orchestration worker-release --dispatch ctx_live'
|
||||
)
|
||||
expect(output).toContain(
|
||||
'ctx_done task=task_done [released/released] attention=none liveness=exited provider=unknown host=local workspace=unknown terminal=none next=none'
|
||||
)
|
||||
})
|
||||
|
||||
it('prints partial host warnings alongside worker rows', async () => {
|
||||
const response = {
|
||||
result: {
|
||||
workers: [
|
||||
{
|
||||
dispatchId: 'ctx_remote',
|
||||
taskId: 'task_remote',
|
||||
runId: 'run_1',
|
||||
workerState: 'running',
|
||||
dispatchStatus: 'dispatched',
|
||||
agentTerminalHandle: 'term_remote',
|
||||
terminalState: 'active',
|
||||
resource: null
|
||||
}
|
||||
],
|
||||
counts: { active: 1 },
|
||||
page: { total: 1, hasMore: false, nextCursor: null },
|
||||
partialHostErrors: [
|
||||
{
|
||||
environmentId: 'environment_windows',
|
||||
name: 'Windows host',
|
||||
code: 'host_unavailable',
|
||||
dispatchIds: ['ctx_remote']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>([['include-remote', true]]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
|
||||
| ((result: (typeof response)['result']) => string)
|
||||
| undefined
|
||||
const output = formatter?.(response.result)
|
||||
expect(output).toContain('ctx_remote task=task_remote [running] terminal=active')
|
||||
expect(output).toContain(
|
||||
'Warning: worker observations from Windows host (environment_windows) are incomplete: host_unavailable; dispatches=ctx_remote'
|
||||
)
|
||||
})
|
||||
|
||||
it('prints partial host warnings when no worker rows are available', async () => {
|
||||
const response = {
|
||||
result: {
|
||||
workers: [],
|
||||
counts: {},
|
||||
page: { total: 0, hasMore: false, nextCursor: null },
|
||||
partialHostErrors: [
|
||||
{
|
||||
environmentId: 'environment_linux',
|
||||
name: 'Linux host',
|
||||
code: 'capability_unsupported',
|
||||
dispatchIds: []
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>([['include-remote', true]]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: false
|
||||
} as never)
|
||||
|
||||
const formatter = vi.mocked(printResult).mock.calls[0]?.[2] as
|
||||
| ((result: (typeof response)['result']) => string)
|
||||
| undefined
|
||||
expect(formatter?.(response.result)).toBe(
|
||||
'No workers found.\nScope: all Runs (no Run is bound to this terminal; pass --run to narrow)' +
|
||||
'\nWarning: worker observations from Linux host (environment_linux) are incomplete: capability_unsupported; dispatches=none'
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves partial host errors in JSON output', async () => {
|
||||
const response = {
|
||||
result: {
|
||||
workers: [],
|
||||
counts: {},
|
||||
page: { total: 0, hasMore: false, nextCursor: null },
|
||||
partialHostErrors: [
|
||||
{
|
||||
environmentId: 'environment_windows',
|
||||
name: 'Windows host',
|
||||
code: 'host_unavailable',
|
||||
dispatchIds: ['ctx_remote']
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>([['include-remote', true]]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(printResult).toHaveBeenCalledWith(
|
||||
{ ...response, result: { ...response.result, scope: { source: 'all' } } },
|
||||
true,
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('parses, forwards, and documents the remote fleet opt-in', async () => {
|
||||
const listSpec = ORCHESTRATION_WORKER_COMMAND_SPECS.find(
|
||||
(spec) => spec.path.join(' ') === 'orchestration worker-list'
|
||||
)
|
||||
expect(BOOLEAN_FLAGS).toContain('include-remote')
|
||||
expect(
|
||||
parseArgs(['orchestration', 'worker-list', '--include-remote']).flags.get('include-remote')
|
||||
).toBe(true)
|
||||
expect(listSpec?.allowedFlags).toContain('include-remote')
|
||||
expect(formatCommandHelp(listSpec!)).toContain(
|
||||
'--include-remote Include connected-server worker observations'
|
||||
)
|
||||
|
||||
callMock.mockResolvedValue({
|
||||
result: {
|
||||
workers: [],
|
||||
counts: {},
|
||||
page: { total: 0, hasMore: false, nextCursor: null }
|
||||
}
|
||||
})
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map<string, string | boolean>([['include-remote', true]]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.workerList',
|
||||
expect.objectContaining({ includeRemote: true, paginate: true })
|
||||
)
|
||||
|
||||
callMock.mockClear()
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags: new Map(),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
const listParams = callMock.mock.calls.find(
|
||||
([method]) => method === 'orchestration.workerList'
|
||||
)?.[1]
|
||||
expect(listParams).toHaveProperty('paginate', true)
|
||||
expect(listParams).not.toHaveProperty('includeRemote')
|
||||
})
|
||||
|
||||
it('keeps cleanup and retention TTL controls off the public CLI surface', async () => {
|
||||
const retainSpec = ORCHESTRATION_WORKER_COMMAND_SPECS.find(
|
||||
(spec) => spec.path.join(' ') === 'orchestration worker-retain'
|
||||
)
|
||||
expect(
|
||||
ORCHESTRATION_WORKER_COMMAND_SPECS.some(
|
||||
(spec) => spec.path.join(' ') === 'orchestration worker-cleanup'
|
||||
)
|
||||
).toBe(false)
|
||||
expect(retainSpec?.allowedFlags).not.toContain('until')
|
||||
expect(retainSpec?.allowedFlags).not.toContain('policy')
|
||||
expect(ORCHESTRATION_HANDLERS['orchestration worker-cleanup']).toBeUndefined()
|
||||
|
||||
callMock.mockResolvedValue({
|
||||
result: { dispatchId: 'ctx_1', state: 'retained', processAction: 'none' }
|
||||
})
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-retain']({
|
||||
flags: new Map<string, string | boolean>([['dispatch', 'ctx_1']]),
|
||||
client: { call: callMock },
|
||||
cwd: '/tmp/repo',
|
||||
json: true
|
||||
} as never)
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.workerRetain', { dispatch: 'ctx_1' })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,7 +22,7 @@ export async function requireWorkerDoneSettlement(
|
||||
tasks: { id: string; status: string; result: string | null }[]
|
||||
}>('orchestration.taskList', { status: target.expectedStatus, run: receipt.runId })
|
||||
]).catch(() => {
|
||||
throw workerDoneSettlementUnknown()
|
||||
throw workerDoneSettlementUnknown(result)
|
||||
})
|
||||
const task = taskVerification.result.tasks.find((candidate) => candidate.id === target.taskId)
|
||||
if (
|
||||
@@ -34,16 +34,32 @@ export async function requireWorkerDoneSettlement(
|
||||
return
|
||||
}
|
||||
}
|
||||
throw workerDoneSettlementUnknown()
|
||||
throw workerDoneSettlementUnknown(result)
|
||||
}
|
||||
|
||||
function workerDoneSettlementUnknown(): RuntimeClientError {
|
||||
function workerDoneSettlementUnknown(result: unknown): RuntimeClientError {
|
||||
const requestId = parseMutationRequestId(result)
|
||||
return new RuntimeClientError(
|
||||
'operation_unknown',
|
||||
'The runtime accepted worker_done but did not confirm that the exact report settled its Task and Dispatch. Retry from the assigned worker after verifying its active Dispatch.'
|
||||
requestId
|
||||
? `The runtime accepted worker_done under mutation request ${requestId}, but the post-verification read did not confirm settlement. Do not send a new completion; inspect the active Dispatch, then re-run the exact same worker_done command with --retry-request ${requestId}.`
|
||||
: 'The runtime accepted worker_done but the post-verification read did not confirm settlement. Do not send a new completion; inspect the active Dispatch and preserve the original mutation identity.',
|
||||
requestId ? { orchestrationRequestId: requestId } : undefined
|
||||
)
|
||||
}
|
||||
|
||||
function parseMutationRequestId(result: unknown): string | undefined {
|
||||
if (!result || typeof result !== 'object' || !('mutation' in result)) {
|
||||
return undefined
|
||||
}
|
||||
const mutation = (result as { mutation?: unknown }).mutation
|
||||
if (!mutation || typeof mutation !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const requestId = (mutation as { requestId?: unknown }).requestId
|
||||
return typeof requestId === 'string' && requestId.length > 0 ? requestId : undefined
|
||||
}
|
||||
|
||||
function parseWorkerDoneReceipt(
|
||||
result: unknown
|
||||
): { messageId: string; runId: string; fromHandle?: string } | undefined {
|
||||
|
||||
@@ -122,14 +122,17 @@ describe('orchestration send structured payload flags', () => {
|
||||
['type', 'heartbeat'],
|
||||
['dispatch-id', 'ctx_1'],
|
||||
['dispatch-capability', 'dcap_secret'],
|
||||
['retry-request', 'mutation_1']
|
||||
['retry-request', '33333333-3333-4333-8333-333333333333']
|
||||
])
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.send',
|
||||
expect.not.objectContaining({ dispatchCapability: expect.anything() }),
|
||||
{ orchestrationCapability: 'dcap_secret', orchestrationRequestId: 'mutation_1' }
|
||||
{
|
||||
orchestrationCapability: 'dcap_secret',
|
||||
orchestrationRequestId: '33333333-3333-4333-8333-333333333333'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -831,6 +834,29 @@ describe('orchestration timeout flag validation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('envelopes ask --json through the shared result printer', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
const response = {
|
||||
id: 'req_ask',
|
||||
ok: true,
|
||||
result: { answer: 'yes', messageId: 'msg_1', threadId: 'thread_1', timedOut: false },
|
||||
_meta: { runtimeId: 'runtime_1' }
|
||||
}
|
||||
callMock.mockResolvedValue(response)
|
||||
vi.mocked(printResult).mockClear()
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await invokeAsk(
|
||||
new Map<string, string | boolean>([
|
||||
['to', 'term_coord'],
|
||||
['question', 'Proceed?']
|
||||
])
|
||||
)
|
||||
|
||||
expect(printResult).toHaveBeenCalledWith(response, true, expect.any(Function))
|
||||
expect(logSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('passes an ask resume without creating a new question payload', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
|
||||
callMock.mockResolvedValue({
|
||||
@@ -875,53 +901,3 @@ describe('orchestration timeout flag validation', () => {
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration task-list brief output', () => {
|
||||
it('requests server-side brief and falls back client-side for older runtimes', async () => {
|
||||
callMock.mockReset().mockResolvedValue({
|
||||
result: {
|
||||
// No spec_truncated field — the pre-brief-runtime signature.
|
||||
tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }],
|
||||
count: 1
|
||||
}
|
||||
})
|
||||
vi.mocked(printResult).mockClear()
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'orchestration.taskList',
|
||||
expect.objectContaining({ brief: true })
|
||||
)
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
expect(response.result.tasks[0].spec).toHaveLength(160)
|
||||
expect(response.result.tasks[0].spec_truncated).toBe(true)
|
||||
})
|
||||
|
||||
it('passes server-abbreviated rows through untouched', async () => {
|
||||
const serverTasks = [
|
||||
{ id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true }
|
||||
]
|
||||
callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } })
|
||||
vi.mocked(printResult).mockClear()
|
||||
|
||||
await ORCHESTRATION_HANDLERS['orchestration task-list']({
|
||||
flags: new Map([['brief', true]]),
|
||||
client: { call: callMock },
|
||||
json: true
|
||||
} as never)
|
||||
|
||||
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
|
||||
result: { tasks: { spec: string; spec_truncated: boolean }[] }
|
||||
}
|
||||
// Why: re-abbreviating a server-truncated spec would flip spec_truncated
|
||||
// back to false (the truncated text fits the cap).
|
||||
expect(response.result.tasks).toBe(serverTasks)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeClient } from '../../runtime-client'
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { readRetryRequestFlag } from '../../retry-request-flag'
|
||||
import { orchestrationMutationRecoveryError } from '../../orchestration-mutation-recovery'
|
||||
|
||||
export function callOrchestrationMutation<TResult>(
|
||||
@@ -9,7 +9,7 @@ export function callOrchestrationMutation<TResult>(
|
||||
params: unknown,
|
||||
options?: { timeoutMs?: number; orchestrationCapability?: string }
|
||||
) {
|
||||
const requestId = getOptionalStringFlag(flags, 'retry-request')
|
||||
const requestId = readRetryRequestFlag(flags)
|
||||
const result = requestId
|
||||
? client.call<TResult>(method, params, { ...options, orchestrationRequestId: requestId })
|
||||
: options
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { printResult } from '../../format'
|
||||
import { renderCommand } from '../../orchestration-mutation-recovery'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import { resolveOrchestrationCliExecutable } from '../../runtime/orchestration-recovery-command'
|
||||
import {
|
||||
clampOrchestrationAskTimeoutMs,
|
||||
resolveOrchestrationAskClientTimeoutMs
|
||||
@@ -65,9 +68,9 @@ export const ORCHESTRATION_QUESTION_HANDLER: Record<string, CommandHandler> = {
|
||||
orchestrationCapability: getOptionalStringFlag(flags, 'dispatch-capability')
|
||||
}
|
||||
)
|
||||
// Why: ask JSON is intentionally a bare object for `jq -r .answer`, unlike other verbs.
|
||||
// Why: same {ok, result} envelope as every sibling verb; ask used to print a bare object.
|
||||
if (json) {
|
||||
console.log(JSON.stringify(result.result))
|
||||
printResult(result, true, () => '')
|
||||
} else if (result.result.legacyCompatibility?.resumeRequired) {
|
||||
console.log(`Question ${result.result.messageId} committed.`)
|
||||
console.log(`Resume with: ${result.result.legacyCompatibility.resumeCommand}`)
|
||||
@@ -91,7 +94,28 @@ export const ORCHESTRATION_QUESTION_HANDLER: Record<string, CommandHandler> = {
|
||||
if (!json) {
|
||||
// Why: report the server's clamped effective budget rather than overstating the wait.
|
||||
const waitedMs = result.result.timeoutMs ?? timeoutMs
|
||||
console.error(`ask timeout after ${waitedMs}ms (thread ${result.result.threadId})`)
|
||||
const messageId = result.result.messageId
|
||||
const dispatchCapability = getOptionalStringFlag(flags, 'dispatch-capability')
|
||||
const resumeCommand =
|
||||
messageId === null
|
||||
? undefined
|
||||
: renderCommand([
|
||||
resolveOrchestrationCliExecutable(),
|
||||
'orchestration',
|
||||
'ask',
|
||||
'--from',
|
||||
from,
|
||||
...(dispatchCapability ? ['--dispatch-capability', dispatchCapability] : []),
|
||||
'--resume',
|
||||
messageId,
|
||||
'--timeout-ms',
|
||||
String(waitedMs)
|
||||
])
|
||||
console.error(
|
||||
resumeCommand
|
||||
? `ask timeout after ${waitedMs}ms; question is still pending (messageId: ${messageId}). Resume waiting; do not ask again:\n${resumeCommand}`
|
||||
: `ask timeout after ${waitedMs}ms; question identity was not returned, so it cannot be resumed safely.`
|
||||
)
|
||||
}
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import type { RuntimeStatus } from '../../../shared/runtime-types'
|
||||
import { ORCHESTRATION_WORKER_LAUNCH_PREFERENCES_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
|
||||
@@ -8,6 +8,8 @@ import { callOrchestrationMutation } from './mutation-request'
|
||||
import { getOptionalPositiveIntegerValueFlag } from './numeric-flags'
|
||||
import { isDevCliInvocation } from './runtime-compatibility'
|
||||
import { resolveCoordinatorTerminalHandle } from './terminal-identity'
|
||||
import { formatWorkerStart } from './worker-output'
|
||||
import { renderResolvedOrchestrationCommand } from '../../orchestration-mutation-recovery'
|
||||
|
||||
export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler> = {
|
||||
'orchestration worker-start': async ({ flags, client, cwd, json }) => {
|
||||
@@ -26,6 +28,11 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler>
|
||||
)
|
||||
}
|
||||
}
|
||||
const task = getOptionalStringFlag(flags, 'task')
|
||||
const spec = getOptionalStringFlag(flags, 'spec')
|
||||
const taskTitle = getOptionalStringFlag(flags, 'task-title')
|
||||
const deps = getOptionalStringFlag(flags, 'deps')
|
||||
const parent = getOptionalStringFlag(flags, 'parent')
|
||||
const result = await callOrchestrationMutation<{
|
||||
runId: string
|
||||
taskId: string
|
||||
@@ -36,8 +43,13 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler>
|
||||
warning?: string
|
||||
effects: unknown[]
|
||||
residualResources: unknown[]
|
||||
nextCommands?: string[]
|
||||
}>(client, flags, 'orchestration.workerStart', {
|
||||
task: getRequiredStringFlag(flags, 'task'),
|
||||
task,
|
||||
...(spec ? { spec } : {}),
|
||||
...(taskTitle ? { taskTitle } : {}),
|
||||
...(deps ? { deps } : {}),
|
||||
...(parent ? { parent } : {}),
|
||||
on: getOptionalStringFlag(flags, 'on'),
|
||||
worktree: getOptionalStringFlag(flags, 'worktree'),
|
||||
name: getOptionalStringFlag(flags, 'name'),
|
||||
@@ -59,12 +71,17 @@ export const ORCHESTRATION_WORKER_LAUNCH_HANDLER: Record<string, CommandHandler>
|
||||
if (result.result.state !== 'ready') {
|
||||
process.exitCode = 1
|
||||
}
|
||||
printResult(result, json, (worker) => {
|
||||
const base = `Worker ${worker.dispatchId} [${worker.state}] for ${worker.taskId}`
|
||||
if (worker.lastError) {
|
||||
return `${base}\n${worker.failedStage ?? 'start'}: ${worker.lastError}`
|
||||
}
|
||||
return worker.warning ? `${base}\nWarning: ${worker.warning}` : base
|
||||
})
|
||||
const renderedResult = result.result.nextCommands
|
||||
? {
|
||||
...result,
|
||||
result: {
|
||||
...result.result,
|
||||
nextCommands: result.result.nextCommands.map((command) =>
|
||||
renderResolvedOrchestrationCommand(command)
|
||||
)
|
||||
}
|
||||
}
|
||||
: result
|
||||
printResult(renderedResult, json, formatWorkerStart)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_HANDLERS } from '../orchestration'
|
||||
|
||||
type Call = { name: string; params: Record<string, unknown> }
|
||||
|
||||
/** The runtime half of this seam (`runCurrent` from a coordinator handle, `workerList` filtered by
|
||||
* `run`) is proven in `rpc/methods/orchestration/worker/worker-list-run-scope-rpc.test.ts`; this
|
||||
* half proves the handler asks exactly those two questions and reports what it decided. */
|
||||
describe('orchestration worker-list Run scope (CLI handler)', () => {
|
||||
const originalTerminalHandle = process.env.ORCA_TERMINAL_HANDLE
|
||||
let calls: Call[]
|
||||
let logged: string[]
|
||||
let boundRun: string | null
|
||||
|
||||
const client = {
|
||||
call: async (name: string, params: Record<string, unknown>) => {
|
||||
calls.push({ name, params })
|
||||
if (name === 'orchestration.runCurrent') {
|
||||
return { result: { run: boundRun ? { id: boundRun } : null } }
|
||||
}
|
||||
if (name === 'orchestration.workerList') {
|
||||
return {
|
||||
result: { workers: [], counts: {}, page: { hasMore: false, nextCursor: null, total: 0 } }
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected call ${name}`)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
calls = []
|
||||
logged = []
|
||||
boundRun = null
|
||||
vi.spyOn(console, 'log').mockImplementation((line: string) => {
|
||||
logged.push(line)
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
if (originalTerminalHandle === undefined) {
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
} else {
|
||||
process.env.ORCA_TERMINAL_HANDLE = originalTerminalHandle
|
||||
}
|
||||
})
|
||||
|
||||
async function list(flags = new Map<string, string | boolean>(), json = true) {
|
||||
await ORCHESTRATION_HANDLERS['orchestration worker-list']({
|
||||
flags,
|
||||
client,
|
||||
cwd: '/tmp/repo',
|
||||
json
|
||||
} as never)
|
||||
const listCall = calls.find((call) => call.name === 'orchestration.workerList')
|
||||
return { listCall, receipt: json ? JSON.parse(logged.at(-1)!).result : null }
|
||||
}
|
||||
|
||||
it('defaults an unscoped list to the Run bound to the calling terminal', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
boundRun = 'run_bound'
|
||||
|
||||
const { listCall, receipt } = await list()
|
||||
|
||||
expect(calls[0]).toEqual({ name: 'orchestration.runCurrent', params: { from: 'term_coord' } })
|
||||
expect(listCall?.params.run).toBe('run_bound')
|
||||
expect(receipt.scope).toEqual({ run: 'run_bound', source: 'bound' })
|
||||
})
|
||||
|
||||
it('keeps --run as the override and never asks for the binding', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
boundRun = 'run_bound'
|
||||
|
||||
const { listCall, receipt } = await list(new Map([['run', 'run_other']]))
|
||||
|
||||
expect(calls.map((call) => call.name)).not.toContain('orchestration.runCurrent')
|
||||
expect(listCall?.params.run).toBe('run_other')
|
||||
expect(receipt.scope).toEqual({ run: 'run_other', source: 'flag' })
|
||||
})
|
||||
|
||||
it('still lists every Run when the caller has no bound Run', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_unbound_shell'
|
||||
boundRun = null
|
||||
|
||||
const { listCall, receipt } = await list()
|
||||
|
||||
expect(listCall?.params.run).toBeUndefined()
|
||||
expect(receipt.scope).toEqual({ source: 'all' })
|
||||
})
|
||||
|
||||
it('names the scope in the human-readable receipt', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
|
||||
boundRun = 'run_bound'
|
||||
|
||||
await list(new Map(), false)
|
||||
|
||||
expect(logged.at(-1)).toContain('Scope: Run run_bound (bound to this terminal)')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import { getOptionalStringFlag } from '../../flags'
|
||||
import type { RuntimeClient } from '../../runtime-client'
|
||||
import { resolveOrchestrationTerminalHandle } from './terminal-identity'
|
||||
|
||||
/** Which Run `worker-list` enumerated, and why. Additive: old readers ignore it. */
|
||||
export type WorkerListRunScope = { run?: string; source: 'flag' | 'bound' | 'all' }
|
||||
|
||||
/**
|
||||
* Unscoped `worker-list` returned every Dispatch the database has ever held. The bound Run is
|
||||
* the same Run `check` reads from the calling terminal, so it is the default and `--run`
|
||||
* overrides it. With no binding to read, listing everything stays the answer and the receipt
|
||||
* says which of the three it was.
|
||||
*/
|
||||
export async function resolveWorkerListRunScope(
|
||||
flags: Map<string, string | boolean>,
|
||||
cwd: string,
|
||||
client: RuntimeClient
|
||||
): Promise<WorkerListRunScope> {
|
||||
const explicit = getOptionalStringFlag(flags, 'run')
|
||||
if (explicit) {
|
||||
return { run: explicit, source: 'flag' }
|
||||
}
|
||||
try {
|
||||
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
|
||||
const current = await client.call<{ run: { id: string } | null }>('orchestration.runCurrent', {
|
||||
from: terminal
|
||||
})
|
||||
return current.result.run ? { run: current.result.run.id, source: 'bound' } : { source: 'all' }
|
||||
} catch {
|
||||
// No live terminal, no stable pane, or a runtime that predates runCurrent: the caller asked
|
||||
// for an inventory, so answer with the whole one rather than failing the enumeration.
|
||||
return { source: 'all' }
|
||||
}
|
||||
}
|
||||
|
||||
export function formatWorkerListScope(scope: WorkerListRunScope): string {
|
||||
if (scope.source === 'all') {
|
||||
return 'Scope: all Runs (no Run is bound to this terminal; pass --run to narrow)'
|
||||
}
|
||||
return `Scope: Run ${scope.run} (${scope.source === 'bound' ? 'bound to this terminal' : '--run'})`
|
||||
}
|
||||
@@ -15,22 +15,37 @@ import { formatWorkerRead, type LegacyWorkerReadResult } from './worker-output'
|
||||
export const ORCHESTRATION_WORKER_OBSERVATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
'orchestration worker-show': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{
|
||||
dispatch: { id: string; task_id: string; status: string }
|
||||
worker: { state: string; stage: string; agent_terminal_handle: string | null }
|
||||
dispatch: { id: string; taskId: string; status: string } | null
|
||||
worker: { state: string; stage: string; agentTerminalHandle: string | null }
|
||||
projection?: { liveness: { verdict: string }; nextAction: { argv: string[] } } | null
|
||||
observation?: { agentWait?: { source: string; reason?: string } | null }
|
||||
}>('orchestration.workerShow', {
|
||||
dispatch: getRequiredStringFlag(flags, 'dispatch')
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
const base = `${value.dispatch.id} task=${value.dispatch.task_id} [${value.worker.state}] stage=${value.worker.stage}`
|
||||
const lines = [
|
||||
`${value.dispatch?.id ?? 'unknown'} task=${value.dispatch?.taskId ?? 'unknown'} [${value.worker.state}] stage=${value.worker.stage}`
|
||||
]
|
||||
// Why: PTY status alone read `live` for an agent that died at a trust prompt, so the
|
||||
// fleet verdict and its next action print beside it rather than in another command.
|
||||
if (value.projection) {
|
||||
lines.push(
|
||||
`Agent liveness: ${value.projection.liveness.verdict}`,
|
||||
`Next action: ${value.projection.nextAction.argv.join(' ') || 'none'}`
|
||||
)
|
||||
}
|
||||
// Why: absent means unknown on older runtimes, distinct from an evaluated null wait.
|
||||
if (value.observation === undefined || !('agentWait' in value.observation)) {
|
||||
return `${base}\nInteractive wait: unknown (not evaluated)`
|
||||
lines.push('Interactive wait: unknown (not evaluated)')
|
||||
} else if (value.observation.agentWait) {
|
||||
const wait = value.observation.agentWait
|
||||
lines.push(
|
||||
`Waiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})`
|
||||
)
|
||||
} else {
|
||||
lines.push('Interactive wait: none')
|
||||
}
|
||||
const wait = value.observation.agentWait
|
||||
return wait
|
||||
? `${base}\nWaiting on a human: ${wait.reason ?? 'interactive prompt'} (via ${wait.source})`
|
||||
: `${base}\nInteractive wait: none`
|
||||
return lines.join('\n')
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { OrchestrationFleetWorker } from '../../../shared/orchestration-fleet-projection'
|
||||
import type { OrchestrationWorkerReadResult } from '../../../shared/orchestration-worker-output'
|
||||
import { formatWorkerRead, formatWorkerStart } from './worker-output'
|
||||
|
||||
function fleetProjection(verdict: 'live' | 'unverifiable' | 'exited'): OrchestrationFleetWorker {
|
||||
return {
|
||||
id: 'dispatch_1',
|
||||
dispatchId: 'dispatch_1',
|
||||
taskId: 'task_1',
|
||||
runId: 'run_1',
|
||||
role: 'worker',
|
||||
parent: null,
|
||||
provider: { id: 'codex', model: null },
|
||||
host: { kind: 'local', id: 'local' },
|
||||
workspace: { id: 'ws_1', kind: 'folder_or_worktree' },
|
||||
stage: { worker: 'ready', dispatch: 'dispatched', detail: null, activity: 'working' },
|
||||
outcome: 'in_progress',
|
||||
liveness:
|
||||
verdict === 'live'
|
||||
? { verdict, observedAt: 1, source: 'agent_status' }
|
||||
: verdict === 'exited'
|
||||
? { verdict, source: 'execution_host' }
|
||||
: { verdict, reason: 'missing_status' },
|
||||
evidence: { durable: true, liveStatus: 'fresh', lastObservedAt: 1 },
|
||||
resource: {
|
||||
state: 'owned',
|
||||
id: 'wtr_1',
|
||||
ownerDispatchId: 'dispatch_1',
|
||||
releaseState: 'active',
|
||||
terminalState: null
|
||||
},
|
||||
nextAction: { kind: 'inspect', argv: [] },
|
||||
attention: { categories: [], requiresAction: false }
|
||||
}
|
||||
}
|
||||
|
||||
describe('worker-start plain formatting', () => {
|
||||
it('renders partial effects, residual resources, and exact recovery commands for unknown starts', () => {
|
||||
const nextCommands = [
|
||||
'orca orchestration worker-show --dispatch ctx_unknown --json',
|
||||
'orca orchestration worker-abandon --dispatch ctx_unknown --json'
|
||||
]
|
||||
|
||||
expect(
|
||||
formatWorkerStart({
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_unknown',
|
||||
state: 'outcome_unknown',
|
||||
failedStage: 'dispatch_input',
|
||||
lastError: 'submission could not be observed',
|
||||
effects: [{ kind: 'terminal', id: 'term_worker' }],
|
||||
residualResources: [{ kind: 'terminal', id: 'term_worker' }],
|
||||
nextCommands
|
||||
})
|
||||
).toBe(
|
||||
'Worker ctx_unknown [outcome_unknown] for task_1\n' +
|
||||
'dispatch_input: submission could not be observed\n' +
|
||||
'Effects: [{"kind":"terminal","id":"term_worker"}]\n' +
|
||||
'Residual resources: [{"kind":"terminal","id":"term_worker"}]\n' +
|
||||
`Next command: ${nextCommands[0]}\n` +
|
||||
`Next command: ${nextCommands[1]}`
|
||||
)
|
||||
})
|
||||
|
||||
it('renders nonempty effects and residual resources for failed starts', () => {
|
||||
expect(
|
||||
formatWorkerStart({
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_failed',
|
||||
state: 'failed',
|
||||
failedStage: 'terminal_create',
|
||||
lastError: 'terminal creation failed',
|
||||
effects: [{ kind: 'worktree', id: 'worktree_1' }],
|
||||
residualResources: [{ kind: 'worktree', id: 'worktree_1' }]
|
||||
})
|
||||
).toBe(
|
||||
'Worker ctx_failed [failed] for task_1\n' +
|
||||
'terminal_create: terminal creation failed\n' +
|
||||
'Effects: [{"kind":"worktree","id":"worktree_1"}]\n' +
|
||||
'Residual resources: [{"kind":"worktree","id":"worktree_1"}]'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps ready receipts concise when effects describe successful setup', () => {
|
||||
expect(
|
||||
formatWorkerStart({
|
||||
taskId: 'task_1',
|
||||
dispatchId: 'ctx_ready',
|
||||
state: 'ready',
|
||||
effects: [{ kind: 'terminal', id: 'term_worker' }],
|
||||
residualResources: []
|
||||
})
|
||||
).toBe('Worker ctx_ready [ready] for task_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker-read plain formatting', () => {
|
||||
it('renders transcript provenance, incomplete coverage, warnings, and opaque cursor guidance', () => {
|
||||
expect(
|
||||
formatWorkerRead(
|
||||
workerReadResult({
|
||||
source: 'transcript',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
provider: 'codex',
|
||||
transcript: {
|
||||
messages: [
|
||||
{
|
||||
id: 'message_1',
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: 'latest output' }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
],
|
||||
nextCursor: 'owr1_transcript',
|
||||
limited: true,
|
||||
returnedMessageCount: 1
|
||||
},
|
||||
cursor: 'owr1_transcript',
|
||||
fallbackReason: null,
|
||||
sourceExact: true,
|
||||
contentComplete: false,
|
||||
clipping: ['message_limit_or_scan_window'],
|
||||
warnings: ['Older transcript records are not pageable through this cursor.']
|
||||
})
|
||||
)
|
||||
).toBe(
|
||||
'Source: transcript (provider=codex)\n' +
|
||||
'Worker: ready\n' +
|
||||
'Archived: false\n' +
|
||||
'Source exact: true\n' +
|
||||
'Content complete: false\n' +
|
||||
'Clipping: message_limit_or_scan_window\n' +
|
||||
'Continuation cursor (opaque; pass unchanged to --cursor): owr1_transcript\n' +
|
||||
'Warning: Older transcript records are not pageable through this cursor.\n\n' +
|
||||
'[assistant] latest output'
|
||||
)
|
||||
})
|
||||
|
||||
it('labels terminal fallback evidence and every warning', () => {
|
||||
expect(
|
||||
formatWorkerRead(
|
||||
workerReadResult({
|
||||
source: 'terminal',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
terminal: {
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['bounded terminal evidence'],
|
||||
truncated: true,
|
||||
nextCursor: '20'
|
||||
},
|
||||
cursor: 'owr1_terminal',
|
||||
fallbackReason: 'session_not_reported',
|
||||
sourceExact: false,
|
||||
contentComplete: false,
|
||||
clipping: ['terminal_buffer', 'terminal_fallback'],
|
||||
warnings: ['A secret was redacted.', 'One line was malformed.']
|
||||
})
|
||||
)
|
||||
).toBe(
|
||||
'Source: terminal\n' +
|
||||
'Worker: ready\n' +
|
||||
'Archived: false\n' +
|
||||
'Source exact: false\n' +
|
||||
'Fallback reason: session_not_reported\n' +
|
||||
'Content complete: false\n' +
|
||||
'Clipping: terminal_buffer, terminal_fallback\n' +
|
||||
'Continuation cursor (opaque; pass unchanged to --cursor): owr1_terminal\n' +
|
||||
'Warning: A secret was redacted.\n' +
|
||||
'Warning: One line was malformed.\n\n' +
|
||||
'bounded terminal evidence'
|
||||
)
|
||||
})
|
||||
|
||||
it('truthfully labels an exact empty transcript without reading terminal evidence', () => {
|
||||
expect(
|
||||
formatWorkerRead(
|
||||
workerReadResult({
|
||||
source: 'transcript',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
provider: 'codex',
|
||||
transcript: {
|
||||
messages: [],
|
||||
nextCursor: 'owr1_empty',
|
||||
limited: false,
|
||||
returnedMessageCount: 0
|
||||
},
|
||||
cursor: 'owr1_empty',
|
||||
fallbackReason: null,
|
||||
sourceExact: true,
|
||||
contentComplete: true,
|
||||
warnings: []
|
||||
})
|
||||
)
|
||||
).toBe(
|
||||
'Source: transcript (provider=codex)\n' +
|
||||
'Worker: ready\n' +
|
||||
'Archived: false\n' +
|
||||
'Source exact: true\n' +
|
||||
'Content complete: true\n' +
|
||||
'Continuation cursor (opaque; pass unchanged to --cursor): owr1_empty\n\n' +
|
||||
'No transcript messages returned. This exact transcript read did not request terminal evidence.'
|
||||
)
|
||||
})
|
||||
it('separates the PTY verdict from the fleet agent verdict', () => {
|
||||
const output = formatWorkerRead({
|
||||
dispatchId: 'dispatch_1',
|
||||
status: { worker: 'ready', terminal: 'running', liveness: 'live' },
|
||||
projection: fleetProjection('unverifiable'),
|
||||
source: 'terminal',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
terminal: {
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['tail'],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
},
|
||||
cursor: null,
|
||||
fallbackReason: null,
|
||||
warnings: []
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal liveness: live')
|
||||
expect(output).toContain('Agent liveness: unverifiable')
|
||||
expect(output).not.toMatch(/^Liveness:/mu)
|
||||
})
|
||||
|
||||
it('omits the agent verdict when the host published no projection', () => {
|
||||
const output = formatWorkerRead({
|
||||
dispatchId: 'dispatch_1',
|
||||
status: { worker: 'ready', terminal: 'running', liveness: 'live' },
|
||||
source: 'terminal',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
terminal: {
|
||||
handle: 'term_worker',
|
||||
status: 'running',
|
||||
tail: ['tail'],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
},
|
||||
cursor: null,
|
||||
fallbackReason: null,
|
||||
warnings: []
|
||||
})
|
||||
|
||||
expect(output).toContain('Terminal liveness: live')
|
||||
expect(output).not.toContain('Agent liveness:')
|
||||
})
|
||||
|
||||
it('distinguishes a released archive read from a live one', () => {
|
||||
const output = formatWorkerRead({
|
||||
dispatchId: 'dispatch_1',
|
||||
status: { worker: 'succeeded', terminal: 'released', liveness: 'unverifiable' },
|
||||
source: 'terminal',
|
||||
sourceIdentity: 'private-source-identity',
|
||||
terminal: {
|
||||
handle: 'term_worker',
|
||||
status: 'exited',
|
||||
tail: ['archived tail'],
|
||||
truncated: false,
|
||||
nextCursor: null
|
||||
},
|
||||
cursor: null,
|
||||
fallbackReason: null,
|
||||
warnings: [],
|
||||
archived: true
|
||||
} as unknown as OrchestrationWorkerReadResult)
|
||||
|
||||
expect(output).toContain('Archived: true')
|
||||
expect(output).toContain('Terminal liveness: unverifiable')
|
||||
expect(output).toContain('Worker: succeeded')
|
||||
})
|
||||
})
|
||||
|
||||
function workerReadResult(
|
||||
value: WorkerReadResultWithoutContext<OrchestrationWorkerReadResult>
|
||||
): OrchestrationWorkerReadResult {
|
||||
return {
|
||||
dispatchId: 'dispatch_1',
|
||||
status: { worker: 'ready', terminal: 'running' },
|
||||
...value
|
||||
} as OrchestrationWorkerReadResult
|
||||
}
|
||||
|
||||
type WorkerReadResultWithoutContext<T> = T extends unknown
|
||||
? Omit<T, 'dispatchId' | 'status'>
|
||||
: never
|
||||
@@ -7,13 +7,98 @@ export type LegacyWorkerReadResult = {
|
||||
terminal: RuntimeTerminalRead
|
||||
}
|
||||
|
||||
export type WorkerStartReceipt = {
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
state: string
|
||||
failedStage?: string
|
||||
lastError?: string
|
||||
warning?: string
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
nextCommands?: string[]
|
||||
}
|
||||
|
||||
export function formatWorkerStart(value: WorkerStartReceipt): string {
|
||||
const lines = [`Worker ${value.dispatchId} [${value.state}] for ${value.taskId}`]
|
||||
if (value.lastError) {
|
||||
lines.push(`${value.failedStage ?? 'start'}: ${value.lastError}`)
|
||||
} else if (value.warning) {
|
||||
lines.push(`Warning: ${value.warning}`)
|
||||
}
|
||||
if (value.state !== 'ready' && (value.state === 'outcome_unknown' || value.effects?.length)) {
|
||||
lines.push(`Effects: ${JSON.stringify(value.effects ?? [])}`)
|
||||
}
|
||||
if (
|
||||
value.state !== 'ready' &&
|
||||
(value.state === 'outcome_unknown' || value.residualResources?.length)
|
||||
) {
|
||||
lines.push(`Residual resources: ${JSON.stringify(value.residualResources ?? [])}`)
|
||||
}
|
||||
if (value.state !== 'ready') {
|
||||
lines.push(...(value.nextCommands ?? []).map((command) => `Next command: ${command}`))
|
||||
}
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
export function formatWorkerRead(
|
||||
value: OrchestrationWorkerReadResult | LegacyWorkerReadResult
|
||||
): string {
|
||||
if (!('source' in value) || value.source === 'terminal') {
|
||||
if (!('source' in value)) {
|
||||
return value.terminal.tail.join('\n')
|
||||
}
|
||||
return value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n')
|
||||
const details = formatWorkerReadDetails(value)
|
||||
const output =
|
||||
value.source === 'terminal'
|
||||
? value.terminal.tail.join('\n')
|
||||
: value.transcript.messages.map(formatWorkerTranscriptMessage).join('\n\n')
|
||||
if (output) {
|
||||
return `${details}\n\n${output}`
|
||||
}
|
||||
const emptyMessage =
|
||||
value.source === 'transcript'
|
||||
? 'No transcript messages returned. This exact transcript read did not request terminal evidence.'
|
||||
: 'No terminal output returned.'
|
||||
return `${details}\n\n${emptyMessage}`
|
||||
}
|
||||
|
||||
function formatWorkerReadDetails(value: OrchestrationWorkerReadResult): string {
|
||||
const source =
|
||||
value.source === 'transcript'
|
||||
? `Source: transcript (provider=${value.provider})`
|
||||
: 'Source: terminal'
|
||||
const lines = [source]
|
||||
// A released archive read otherwise prints identically to a live one.
|
||||
if (value.status?.worker) {
|
||||
lines.push(`Worker: ${value.status.worker}`)
|
||||
}
|
||||
lines.push(`Archived: ${value.archived === true}`)
|
||||
// Two different verdicts: status.liveness is the PTY's, the fleet projection is the agent's.
|
||||
if (value.status?.liveness) {
|
||||
lines.push(`Terminal liveness: ${value.status.liveness}`)
|
||||
}
|
||||
if (value.projection) {
|
||||
lines.push(`Agent liveness: ${value.projection.liveness.verdict}`)
|
||||
}
|
||||
if (value.sourceExact !== undefined) {
|
||||
lines.push(`Source exact: ${value.sourceExact}`)
|
||||
}
|
||||
if (value.fallbackReason) {
|
||||
lines.push(`Fallback reason: ${value.fallbackReason}`)
|
||||
}
|
||||
if (value.contentComplete !== undefined) {
|
||||
lines.push(`Content complete: ${value.contentComplete}`)
|
||||
}
|
||||
if (value.clipping?.length) {
|
||||
lines.push(`Clipping: ${value.clipping.join(', ')}`)
|
||||
}
|
||||
lines.push(
|
||||
value.cursor
|
||||
? `Continuation cursor (opaque; pass unchanged to --cursor): ${value.cursor}`
|
||||
: 'Continuation cursor: unavailable'
|
||||
)
|
||||
lines.push(...(value.warnings ?? []).map((warning) => `Warning: ${warning}`))
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatWorkerTranscriptMessage(message: NativeChatMessage): string {
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import type { CommandHandler } from '../../dispatch'
|
||||
import { printResult } from '../../format'
|
||||
import { getOptionalStringFlag, getRequiredStringFlag } from '../../flags'
|
||||
import {
|
||||
getOptionalPositiveIntegerFlag,
|
||||
getOptionalStringFlag,
|
||||
getRequiredStringFlag
|
||||
} from '../../flags'
|
||||
import { RuntimeClientError } from '../../runtime-client'
|
||||
import { callOrchestrationMutation } from './mutation-request'
|
||||
import { formatWorkerRelease, type WorkerReleaseReceipt } from './worker-output'
|
||||
import {
|
||||
formatWorkerListScope,
|
||||
resolveWorkerListRunScope,
|
||||
type WorkerListRunScope
|
||||
} from './worker-list-run-scope'
|
||||
|
||||
const WORKER_TERMINAL_LIST_STATES = [
|
||||
'active',
|
||||
@@ -78,7 +87,7 @@ export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record<string, CommandHandl
|
||||
printResult(result, json, formatWorkerRelease)
|
||||
},
|
||||
|
||||
'orchestration worker-list': async ({ flags, client, json }) => {
|
||||
'orchestration worker-list': async ({ flags, client, cwd, json }) => {
|
||||
const terminalState = getOptionalStringFlag(flags, 'terminal-state')
|
||||
if (
|
||||
terminalState &&
|
||||
@@ -91,6 +100,9 @@ export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record<string, CommandHandl
|
||||
`invalid --terminal-state '${terminalState}', expected one of: ${WORKER_TERMINAL_LIST_STATES.join(', ')}`
|
||||
)
|
||||
}
|
||||
const scope = await resolveWorkerListRunScope(flags, cwd, client)
|
||||
const requiresCurrentListSemantics =
|
||||
flags.has('include-remote') || flags.has('cursor') || flags.has('limit')
|
||||
const result = await client.call<{
|
||||
workers: {
|
||||
dispatchId: string
|
||||
@@ -101,26 +113,77 @@ export const ORCHESTRATION_WORKER_TERMINAL_HANDLERS: Record<string, CommandHandl
|
||||
agentTerminalHandle: string | null
|
||||
terminalState: string | null
|
||||
resource: unknown
|
||||
projection?: {
|
||||
provider: { id: string; model: string | null } | null
|
||||
host: { id: string }
|
||||
workspace: { id: string } | null
|
||||
stage: { activity: string }
|
||||
liveness: { verdict: string }
|
||||
nextAction: { argv: string[] }
|
||||
attention?: { categories: string[] }
|
||||
}
|
||||
}[]
|
||||
counts: Record<string, number>
|
||||
scope?: WorkerListRunScope
|
||||
page?: { hasMore: boolean; nextCursor: string | null; total: number }
|
||||
partialHostErrors?: {
|
||||
environmentId: string
|
||||
name: string
|
||||
code: string
|
||||
dispatchIds: string[]
|
||||
}[]
|
||||
}>('orchestration.workerList', {
|
||||
run: getOptionalStringFlag(flags, 'run'),
|
||||
terminalState
|
||||
paginate: true,
|
||||
run: scope.run,
|
||||
terminalState,
|
||||
...(flags.has('include-remote') ? { includeRemote: true } : {}),
|
||||
cursor: getOptionalStringFlag(flags, 'cursor'),
|
||||
limit: getOptionalPositiveIntegerFlag(flags, 'limit')
|
||||
})
|
||||
printResult(result, json, (value) => {
|
||||
if (value.workers.length === 0) {
|
||||
return 'No workers found.'
|
||||
}
|
||||
const rows = value.workers
|
||||
.map(
|
||||
(worker) =>
|
||||
`${worker.dispatchId} task=${worker.taskId} [${worker.workerState}] terminal=${worker.terminalState ?? 'none'}`
|
||||
)
|
||||
.join('\n')
|
||||
if (requiresCurrentListSemantics && !result.result.page) {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'The connected Orca runtime did not prove support for the requested worker-list flags, so no inventory was printed. Update the connected Orca runtime and retry.'
|
||||
)
|
||||
}
|
||||
printResult({ ...result, result: { ...result.result, scope } }, json, (value) => {
|
||||
const rows =
|
||||
value.workers.length === 0
|
||||
? 'No workers found.'
|
||||
: value.workers
|
||||
.map((worker) => {
|
||||
const projection = worker.projection
|
||||
const provider = projection?.provider
|
||||
? `${projection.provider.id}${projection.provider.model ? `/${projection.provider.model}` : ''}`
|
||||
: 'unknown'
|
||||
const workspace = projection?.workspace?.id ?? 'unknown'
|
||||
const stage = projection?.stage.activity ?? worker.dispatchStatus
|
||||
const liveness = projection?.liveness.verdict
|
||||
const attention = projection?.attention?.categories.join(',') || 'none'
|
||||
const details = projection
|
||||
? `/${stage}] attention=${attention} liveness=${liveness} provider=${provider} host=${projection.host.id} workspace=${workspace}`
|
||||
: `]`
|
||||
// Why: the enumerating command owes the literal argv the guides tell callers to run.
|
||||
const next = projection
|
||||
? ` next=${projection.nextAction.argv.join(' ') || 'none'}`
|
||||
: ''
|
||||
return `${worker.dispatchId} task=${worker.taskId} [${worker.workerState}${details} terminal=${worker.terminalState ?? 'none'}${next}`
|
||||
})
|
||||
.join('\n')
|
||||
const counts = Object.entries(value.counts)
|
||||
.map(([state, count]) => `${state}=${count}`)
|
||||
.join(' ')
|
||||
return counts ? `${rows}\nTerminals: ${counts}` : rows
|
||||
const pagination =
|
||||
value.page?.hasMore && value.page.nextCursor
|
||||
? `\nMore: --cursor ${value.page.nextCursor}`
|
||||
: ''
|
||||
const warnings = (value.partialHostErrors ?? []).map(
|
||||
(error) =>
|
||||
`Warning: worker observations from ${error.name} (${error.environmentId}) are incomplete: ${error.code}; dispatches=${error.dispatchIds.join(',') || 'none'}`
|
||||
)
|
||||
const warningBlock = warnings.length ? `\n${warnings.join('\n')}` : ''
|
||||
const scopeLine = `\n${formatWorkerListScope(value.scope ?? scope)}`
|
||||
return `${counts ? `${rows}\nTerminals: ${counts}` : rows}${scopeLine}${pagination}${warningBlock}`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { writeStdoutLine } from '../stdout-line'
|
||||
import {
|
||||
loadCanonicalGuides,
|
||||
requireTopic,
|
||||
type BundledSkillGuide,
|
||||
type BundledSkillGuideReference
|
||||
} from './bundled-skill-guide-table'
|
||||
|
||||
type GuideSelection = { full: boolean; reference: string | null; listReferences: boolean }
|
||||
|
||||
// Why: the kernel's gate table names each document as `references/<file>.md`, so that
|
||||
// exact string must resolve as well as the bare name an agent is likely to retype.
|
||||
function normalizeReferenceSelector(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/^references\//, '')
|
||||
.replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function resolveSelection(flags: Map<string, string | boolean>): GuideSelection {
|
||||
const full = flags.has('full')
|
||||
const listReferences = flags.get('references') === true
|
||||
const requested = flags.get('reference')
|
||||
const hasReference = flags.has('reference')
|
||||
if (listReferences && full) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --references or --full, not both.')
|
||||
}
|
||||
if (listReferences && hasReference) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --references or --reference, not both.'
|
||||
)
|
||||
}
|
||||
if (full && hasReference) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Use either --full or --reference, not both.')
|
||||
}
|
||||
if (hasReference && (typeof requested !== 'string' || requested.trim().length === 0)) {
|
||||
throw new RuntimeClientError('invalid_argument', 'Missing required --reference')
|
||||
}
|
||||
return {
|
||||
full,
|
||||
reference: typeof requested === 'string' ? requested : null,
|
||||
listReferences
|
||||
}
|
||||
}
|
||||
|
||||
function requireReferences(guide: BundledSkillGuide): readonly BundledSkillGuideReference[] {
|
||||
if (guide.references.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Guide "${guide.name}" has no bundled references.`
|
||||
)
|
||||
}
|
||||
return guide.references
|
||||
}
|
||||
|
||||
function requireReference(guide: BundledSkillGuide, requested: string): BundledSkillGuideReference {
|
||||
const references = requireReferences(guide)
|
||||
const selector = normalizeReferenceSelector(requested)
|
||||
const match = references.find((reference) => reference.name === selector)
|
||||
if (!match) {
|
||||
const available = references.map((reference) => reference.name).join(', ')
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown reference "${requested}" for ${guide.name}. Available: ${available}`
|
||||
)
|
||||
}
|
||||
return match
|
||||
}
|
||||
|
||||
export const SKILL_GUIDE_GET_HANDLER: Record<string, CommandHandler> = {
|
||||
'skills get': async ({ flags, json }) => {
|
||||
const selection = resolveSelection(flags)
|
||||
const guides = await loadCanonicalGuides()
|
||||
const guide = requireTopic(flags, guides)
|
||||
|
||||
if (selection.listReferences) {
|
||||
const names = requireReferences(guide).map((reference) => reference.name)
|
||||
writeStdoutLine(
|
||||
json ? JSON.stringify({ name: guide.name, references: names }, null, 2) : names.join('\n')
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (selection.reference !== null) {
|
||||
const reference = requireReference(guide, selection.reference)
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify(
|
||||
{ name: guide.name, reference: reference.name, markdown: reference.markdown },
|
||||
null,
|
||||
2
|
||||
)
|
||||
: reference.markdown
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const markdown = selection.full ? guide.fullMarkdown : guide.markdown
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ name: guide.name, full: selection.full, markdown }, null, 2)
|
||||
: markdown
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { spawn } from 'node:child_process'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getRepeatedStringFlag } from '../flags'
|
||||
import { writeStdoutLine } from '../stdout-line'
|
||||
import { loadCanonicalGuides, type BundledSkillGuide } from './bundled-skill-guide-table'
|
||||
import { SKILL_GUIDE_GET_HANDLER } from './skill-guide-get'
|
||||
import { resolveCliCommand, withCliRuntimeOnPath } from '../../shared/node-cli-command-resolution'
|
||||
import { detectCommandsInInstallDirs } from '../../shared/local-agent-install-dir-detection'
|
||||
import {
|
||||
@@ -20,51 +23,6 @@ import {
|
||||
buildAgentFeatureSkillUpdateArgs
|
||||
} from '../../shared/agent-feature-install-commands'
|
||||
|
||||
type BundledSkillGuide = {
|
||||
name: string
|
||||
description: string
|
||||
markdown: string
|
||||
fullMarkdown: string
|
||||
aliases: readonly string[]
|
||||
}
|
||||
|
||||
function canonicalGuides(guides: readonly BundledSkillGuide[]): BundledSkillGuide[] {
|
||||
return [...guides].sort((left, right) =>
|
||||
left.name < right.name ? -1 : left.name > right.name ? 1 : 0
|
||||
)
|
||||
}
|
||||
|
||||
function requireTopic(
|
||||
flags: Map<string, string | boolean>,
|
||||
guides: BundledSkillGuide[]
|
||||
): BundledSkillGuide {
|
||||
const availableTopics = guides.map((guide) => guide.name).join(', ')
|
||||
const topic = flags.get('topic')
|
||||
if (typeof topic !== 'string' || topic.length === 0) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Missing skill topic. Available topics: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
// Why: installed stubs may retain an old topic forever, so aliases and canonical
|
||||
// names share one lookup table instead of being treated as transient CLI aliases.
|
||||
const guideByTopic = new Map<string, BundledSkillGuide>(
|
||||
guides.flatMap((guide) => [guide.name, ...guide.aliases].map((name) => [name, guide]))
|
||||
)
|
||||
const guide = guideByTopic.get(topic)
|
||||
if (!guide) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown skill topic "${topic}". Available topics: ${availableTopics}`
|
||||
)
|
||||
}
|
||||
return guide
|
||||
}
|
||||
|
||||
function writeStdout(value: string): void {
|
||||
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
|
||||
}
|
||||
|
||||
function resolveSelectedSkillNames(
|
||||
flags: Map<string, string | boolean>,
|
||||
guides: BundledSkillGuide[]
|
||||
@@ -251,14 +209,12 @@ function formatSkillSelectionHelp(verb: SkillMutationVerb, skillNames: string[])
|
||||
|
||||
function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
|
||||
return async ({ flags, json }) => {
|
||||
// Why: keep the large generated table off the eager handler registry path.
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
const guides = await loadCanonicalGuides()
|
||||
const skillNames = resolveSelectedSkillNames(flags, guides)
|
||||
|
||||
if (skillNames.length === 0) {
|
||||
const names = guides.map((guide) => guide.name)
|
||||
writeStdout(
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ availableSkills: names }, null, 2)
|
||||
: formatSkillSelectionHelp(verb, names)
|
||||
@@ -286,7 +242,7 @@ function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
|
||||
const dryRun = flags.get('dry-run') === true
|
||||
|
||||
if (dryRun) {
|
||||
writeStdout(
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ command, skills: skillNames, global, executed: false }, null, 2)
|
||||
: `${command}\n\nRerun without --dry-run to ${verb} now.`
|
||||
@@ -313,31 +269,19 @@ function createSkillMutationHandler(verb: SkillMutationVerb): CommandHandler {
|
||||
|
||||
export const SKILL_HANDLERS: Record<string, CommandHandler> = {
|
||||
'skills list': async ({ json }) => {
|
||||
// Why: the embedded guide table is large, so unrelated CLI commands must not
|
||||
// pay its module-load and parse cost during startup.
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
// Why: generated registry order is not a user-facing contract, while stable
|
||||
// canonical sorting keeps agent-visible output reproducible across builds.
|
||||
const topics = guides.map((guide) => ({
|
||||
const topics = (await loadCanonicalGuides()).map((guide) => ({
|
||||
name: guide.name,
|
||||
description: guide.description.replace(/\s+/g, ' ').trim()
|
||||
}))
|
||||
writeStdout(
|
||||
writeStdoutLine(
|
||||
json
|
||||
? JSON.stringify({ topics }, null, 2)
|
||||
: topics.map((topic) => `${topic.name}: ${topic.description}`).join('\n')
|
||||
)
|
||||
},
|
||||
'skills get': async ({ flags, json }) => {
|
||||
// Why: keep the large generated table off the eager handler registry path.
|
||||
const { BUNDLED_SKILL_GUIDES } = await import('../bundled-skill-guides.js')
|
||||
const guides = canonicalGuides(BUNDLED_SKILL_GUIDES)
|
||||
const guide = requireTopic(flags, guides)
|
||||
const full = flags.has('full')
|
||||
const markdown = full ? guide.fullMarkdown : guide.markdown
|
||||
writeStdout(json ? JSON.stringify({ name: guide.name, full, markdown }, null, 2) : markdown)
|
||||
},
|
||||
...SKILL_GUIDE_GET_HANDLER,
|
||||
'skills install': createSkillMutationHandler('install'),
|
||||
'skills update': createSkillMutationHandler('update')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeWorktreeTerminalCloseResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatTerminalClose, reportCliError, printResult } from '../format'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { getRequiredWorktreeSelector, getTerminalHandle } from '../selectors'
|
||||
|
||||
/** A false stop receipt is an error only when the host supplied a liveness verdict. */
|
||||
function terminalCloseFailure(close: RuntimeTerminalClose): RuntimeClientError | null {
|
||||
if (close.ptyKilled || close.ptyStopVerdict === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const verdict = close.ptyStopVerdict
|
||||
const detail =
|
||||
verdict === 'live'
|
||||
? 'The PTY is live.'
|
||||
: `The PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its host could not be reached'}.`
|
||||
return new RuntimeClientError(
|
||||
verdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
|
||||
`Terminal ${close.handle} close failed to confirm the PTY stopped (${verdict}). ${detail}`,
|
||||
{ close }
|
||||
)
|
||||
}
|
||||
|
||||
function terminalCloseAllFailure(
|
||||
close: RuntimeWorktreeTerminalCloseResult
|
||||
): RuntimeClientError | null {
|
||||
if (!close.ptyStopVerdict) {
|
||||
return null
|
||||
}
|
||||
const detail =
|
||||
close.ptyStopVerdict === 'live'
|
||||
? 'At least one PTY is live.'
|
||||
: `At least one PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its owning host could not be reached'}.`
|
||||
return new RuntimeClientError(
|
||||
close.ptyStopVerdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
|
||||
`Workspace terminal close did not confirm every PTY stopped (${close.ptyStopVerdict}). ${detail}`,
|
||||
{ close }
|
||||
)
|
||||
}
|
||||
|
||||
export const terminalCloseHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
|
||||
if (flags.get('all') === true) {
|
||||
if (flags.has('terminal') || flags.get('tab') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--all uses --worktree and cannot be combined with --terminal or --tab'
|
||||
)
|
||||
}
|
||||
try {
|
||||
const result = await client.call<RuntimeWorktreeTerminalCloseResult>('terminal.closeAll', {
|
||||
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
|
||||
})
|
||||
const failure = terminalCloseAllFailure(result.result)
|
||||
if (failure) {
|
||||
reportCliError(failure, json)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) =>
|
||||
`Closed ${value.closed} terminal tabs and stopped ${value.stopped} terminal processes.`
|
||||
)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeClientError && error.code === 'method_not_found') {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host does not support closing every terminal in a workspace yet. Update Orca on the host and try again.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (flags.has('worktree')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Closing a workspace requires --all: terminal close --worktree <selector> --all'
|
||||
)
|
||||
}
|
||||
const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close'
|
||||
const result = await client.call<{ close: RuntimeTerminalClose }>(method, {
|
||||
terminal: await getTerminalHandle(flags, cwd, client)
|
||||
})
|
||||
// Why: a transport-level success must not hide a live or unverifiable PTY. Keep the receipt in
|
||||
// error.data so JSON callers retain the host's exact evidence while receiving a failing outcome.
|
||||
const failure = terminalCloseFailure(result.result.close)
|
||||
if (failure) {
|
||||
// Keep the established human receipt (including its liveness warning); JSON needs the
|
||||
// standard failure envelope so callers do not mistake transport success for a stopped PTY.
|
||||
if (json) {
|
||||
reportCliError(failure, true)
|
||||
} else {
|
||||
printResult(result, false, formatTerminalClose)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
printResult(result, json, formatTerminalClose)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { RuntimeTerminalSend } from '../../shared/runtime-types'
|
||||
import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatTerminalSend, printResult, terminalSendWarnings } from '../format'
|
||||
import { getOptionalPositiveIntegerFlag, getOptionalStringFlag } from '../flags'
|
||||
import { readRetryRequestFlag } from '../retry-request-flag'
|
||||
import { RuntimeClientError } from '../runtime-client'
|
||||
import { attachUnverifiedTerminalPromptRecovery } from '../runtime/terminal-prompt-mutation-recovery'
|
||||
import { getTerminalHandle } from '../selectors'
|
||||
|
||||
type TerminalSendResult = { send: RuntimeTerminalSend; warnings?: string[] }
|
||||
|
||||
export const terminalSendHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
|
||||
const text = getOptionalStringFlag(flags, 'text')
|
||||
const enter = flags.get('enter') === true
|
||||
const interrupt = flags.get('interrupt') === true
|
||||
const promptCandidate = !!text && enter && !interrupt
|
||||
const retryRequest = readRetryRequestFlag(flags)
|
||||
const waitSubmitSeconds = getOptionalPositiveIntegerFlag(flags, 'wait-submit')
|
||||
if ((retryRequest || waitSubmitSeconds) && !promptCandidate) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--retry-request and --wait-submit require --text with --enter and without --interrupt.'
|
||||
)
|
||||
}
|
||||
if (waitSubmitSeconds && waitSubmitSeconds > 3600) {
|
||||
throw new RuntimeClientError('invalid_argument', '--wait-submit must be at most 3600 seconds.')
|
||||
}
|
||||
const waitSubmitMs = waitSubmitSeconds ? waitSubmitSeconds * 1000 : undefined
|
||||
let promptDeliverySupported = false
|
||||
let promptDeliveryRuntimeId: string | null = null
|
||||
if (promptCandidate) {
|
||||
const status = await client.getCliStatus()
|
||||
if (!status.result.runtime.reachable) {
|
||||
throw new RuntimeClientError(
|
||||
'runtime_unavailable',
|
||||
'Orca could not verify prompt-delivery support, so no input was sent. Wait for the execution host to become reachable and retry.'
|
||||
)
|
||||
}
|
||||
promptDeliverySupported =
|
||||
status.result.runtime.capabilities?.includes(TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY) ===
|
||||
true
|
||||
promptDeliveryRuntimeId = status.result.runtime.runtimeId
|
||||
}
|
||||
if (retryRequest && !promptDeliverySupported) {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host cannot honor --retry-request and never recorded this request ID. This attempt sent no input, but an earlier prompt may have been delivered; inspect the terminal and do not resend unless you independently prove it was not delivered, because updating the host cannot make this specific retry idempotent.'
|
||||
)
|
||||
}
|
||||
if (waitSubmitMs && !promptDeliverySupported) {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host does not support --wait-submit. No input was sent; update Orca on the execution host, or omit only --wait-submit for a legacy prompt whose delivery cannot be observed or retried safely.'
|
||||
)
|
||||
}
|
||||
const params = {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
text,
|
||||
enter,
|
||||
interrupt,
|
||||
...(promptCandidate
|
||||
? {
|
||||
agentPrompt: true as const,
|
||||
...(waitSubmitMs ? { waitSubmitMs } : {})
|
||||
}
|
||||
: {}),
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
}
|
||||
const options = promptDeliverySupported
|
||||
? {
|
||||
terminalPromptPreflight: { runtimeId: promptDeliveryRuntimeId },
|
||||
...(retryRequest ? { orchestrationRequestId: retryRequest } : {}),
|
||||
...(waitSubmitMs ? { timeoutMs: waitSubmitMs + 10_000 } : {})
|
||||
}
|
||||
: promptCandidate
|
||||
? { legacyTerminalPrompt: true as const }
|
||||
: undefined
|
||||
const result = options
|
||||
? await client.call<TerminalSendResult>('terminal.send', params, options)
|
||||
: await client.call<TerminalSendResult>('terminal.send', params)
|
||||
const missingPromptReceipt =
|
||||
promptCandidate && result.result.send.accepted && !result.result.send.prompt
|
||||
if (missingPromptReceipt && promptDeliverySupported) {
|
||||
throw attachUnverifiedTerminalPromptRecovery(
|
||||
new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'The Orca host changed after prompt-delivery support was verified and accepted input without returning a durable prompt receipt.'
|
||||
)
|
||||
)
|
||||
}
|
||||
if (missingPromptReceipt) {
|
||||
result.result.send.prompt = {
|
||||
requestId: 'unsupported-old-host',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'old-host',
|
||||
observation: 'unsupported',
|
||||
processIncarnation: 'unknown',
|
||||
generation: 0,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
// Why: the delivery warnings only existed in the text formatter, so --json callers never saw them.
|
||||
const warnings = terminalSendWarnings(result.result.send)
|
||||
printResult(
|
||||
warnings.length > 0 ? { ...result, result: { ...result.result, warnings } } : result,
|
||||
json,
|
||||
formatTerminalSend
|
||||
)
|
||||
if (!result.result.send.accepted) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { RuntimeClientError, type RuntimeClient } from '../runtime-client'
|
||||
import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { parseArgs } from '../args'
|
||||
import { printHelp } from '../help'
|
||||
import { COMMAND_SPECS } from '../specs'
|
||||
@@ -250,6 +251,20 @@ describe('terminal close CLI', () => {
|
||||
})
|
||||
|
||||
describe('terminal send CLI', () => {
|
||||
const promptClient = (call: ReturnType<typeof vi.fn>, supported: boolean) =>
|
||||
({
|
||||
call,
|
||||
getCliStatus: vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
runtime: {
|
||||
reachable: true,
|
||||
runtimeId: 'runtime-current',
|
||||
capabilities: supported ? [TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY] : []
|
||||
}
|
||||
}
|
||||
})
|
||||
}) as unknown as RuntimeClient
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = ORIGINAL_EXIT_CODE
|
||||
@@ -257,7 +272,22 @@ describe('terminal send CLI', () => {
|
||||
|
||||
it('marks combined text and Enter as an agent prompt candidate', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 7 } }
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 7,
|
||||
prompt: {
|
||||
requestId: '11111111-1111-4111-8111-111111111111',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'codex',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -267,19 +297,60 @@ describe('terminal send CLI', () => {
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
client: promptClient(call, true),
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith('terminal.send', {
|
||||
terminal: 'term-1',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-1',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{ terminalPromptPreflight: { runtimeId: 'runtime-current' } }
|
||||
)
|
||||
})
|
||||
|
||||
it('carries the swallowed-Enter warning into the --json receipt', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 7,
|
||||
prompt: {
|
||||
requestId: 'prompt-swallowed',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'claude',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: promptClient(call, true),
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(JSON.parse(String(log.mock.calls[0]?.[0])).result.warnings).toEqual([
|
||||
expect.stringContaining('no turn start was observed')
|
||||
])
|
||||
})
|
||||
|
||||
it('explains that Structured Chat blocked a refused send and how to recover', async () => {
|
||||
@@ -302,6 +373,7 @@ describe('terminal send CLI', () => {
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
process.exitCode = undefined
|
||||
const client = promptClient(call, true)
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
@@ -309,11 +381,12 @@ describe('terminal send CLI', () => {
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: { call } as unknown as RuntimeClient,
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: false
|
||||
})
|
||||
|
||||
expect(client.getCliStatus).toHaveBeenCalledOnce()
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Structured Chat.*Switch it to Terminal.*orca terminal send/s)
|
||||
)
|
||||
@@ -360,4 +433,255 @@ describe('terminal send CLI', () => {
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
})
|
||||
})
|
||||
|
||||
it('passes retry identity and observation wait only for agent prompts', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 8,
|
||||
prompt: {
|
||||
requestId: '11111111-1111-4111-8111-111111111111',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'codex',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'continue'],
|
||||
['enter', true],
|
||||
['retry-request', '11111111-1111-4111-8111-111111111111'],
|
||||
['wait-submit', '3']
|
||||
]),
|
||||
client: promptClient(call, true),
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
expect.objectContaining({ agentPrompt: true, waitSubmitMs: 3_000 }),
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: '11111111-1111-4111-8111-111111111111',
|
||||
timeoutMs: 13_000
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('fails closed when the host downgrades after the prompt capability preflight', async () => {
|
||||
const response = {
|
||||
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 8 } },
|
||||
_meta: { runtimeId: 'old-runtime-after-restart' }
|
||||
}
|
||||
const call = vi.fn().mockResolvedValue(response)
|
||||
const client = {
|
||||
call,
|
||||
getCliStatus: vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
runtime: {
|
||||
reachable: true,
|
||||
runtimeId: 'new-runtime-before-restart',
|
||||
capabilities: [TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY]
|
||||
}
|
||||
}
|
||||
})
|
||||
} as unknown as RuntimeClient
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const error = await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'continue'],
|
||||
['enter', true],
|
||||
['retry-request', '11111111-1111-4111-8111-111111111111'],
|
||||
['wait-submit', '3']
|
||||
]),
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
expect.objectContaining({ agentPrompt: true, waitSubmitMs: 3_000 }),
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'new-runtime-before-restart' },
|
||||
orchestrationRequestId: '11111111-1111-4111-8111-111111111111',
|
||||
timeoutMs: 13_000
|
||||
}
|
||||
)
|
||||
expect(error).toMatchObject({
|
||||
code: 'incompatible_runtime',
|
||||
data: {
|
||||
deliveryOutcome: 'unknown',
|
||||
retrySafe: false,
|
||||
nextSteps: expect.arrayContaining([expect.stringContaining('Inspect the terminal output')])
|
||||
}
|
||||
})
|
||||
expect((error as Error).message).toContain('cannot prove whether the prompt was delivered')
|
||||
expect((response.result.send as { prompt?: unknown }).prompt).toBeUndefined()
|
||||
expect(log).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels an old-host response as non-idempotent without claiming submission', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: { send: { handle: 'term-1', accepted: true, bytesWritten: 7 } }
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: promptClient(call, false),
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
expect.objectContaining({ agentPrompt: true }),
|
||||
{ legacyTerminalPrompt: true }
|
||||
)
|
||||
expect(call.mock.results[0]?.value).toBeDefined()
|
||||
const response = await call.mock.results[0]?.value
|
||||
expect(response.result.send.prompt).toEqual({
|
||||
requestId: 'unsupported-old-host',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'old-host',
|
||||
observation: 'unsupported',
|
||||
processIncarnation: 'unknown',
|
||||
generation: 0,
|
||||
baselineWorkingSequence: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('does not fabricate an accepted prompt receipt for an old-host refusal', async () => {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: false,
|
||||
bytesWritten: 0,
|
||||
refusedReason: 'permission'
|
||||
}
|
||||
}
|
||||
})
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true]
|
||||
]),
|
||||
client: promptClient(call, false),
|
||||
cwd: '/tmp/worktree',
|
||||
json: false
|
||||
})
|
||||
|
||||
const response = await call.mock.results[0]?.value
|
||||
expect(response.result.send.prompt).toBeUndefined()
|
||||
expect(String(log.mock.calls[0]?.[0])).toBe('Input refused by term-1: permission.')
|
||||
})
|
||||
|
||||
it('refuses old-host retry before sending any input', async () => {
|
||||
const call = vi.fn()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
const error = await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'review'],
|
||||
['enter', true],
|
||||
['retry-request', '11111111-1111-4111-8111-111111111111']
|
||||
]),
|
||||
client: {
|
||||
call,
|
||||
getCliStatus: vi.fn().mockResolvedValue({
|
||||
result: { runtime: { reachable: true, capabilities: [] } }
|
||||
})
|
||||
} as unknown as RuntimeClient,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toMatchObject({ code: 'incompatible_runtime' })
|
||||
expect((error as Error).message).toContain(
|
||||
'updating the host cannot make this specific retry idempotent'
|
||||
)
|
||||
expect((error as Error).message).not.toContain('omit --retry-request')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves retry identity after a pre-write host failure', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new RuntimeClientError('internal_error', 'terminal_not_writable'))
|
||||
.mockResolvedValueOnce({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 13,
|
||||
prompt: {
|
||||
requestId: '22222222-2222-4222-8222-222222222222',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'codex',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
const client = promptClient(call, true)
|
||||
const flags = new Map<string, string | true>([
|
||||
['terminal', 'term-1'],
|
||||
['text', 'retry safely'],
|
||||
['enter', true],
|
||||
['retry-request', '22222222-2222-4222-8222-222222222222']
|
||||
])
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await expect(
|
||||
TERMINAL_HANDLERS['terminal send']({ flags, client, cwd: '/tmp/worktree', json: true })
|
||||
).rejects.toMatchObject({ message: 'terminal_not_writable' })
|
||||
await TERMINAL_HANDLERS['terminal send']({
|
||||
flags,
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
expect(call.mock.calls.map((args) => args[2])).toEqual([
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: '22222222-2222-4222-8222-222222222222'
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: '22222222-2222-4222-8222-222222222222'
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalCreate,
|
||||
RuntimeTerminalFocus,
|
||||
RuntimeTerminalListResult,
|
||||
RuntimeTerminalRead,
|
||||
RuntimeTerminalRename,
|
||||
RuntimeTerminalSend,
|
||||
RuntimeTerminalShow,
|
||||
RuntimeTerminalSplit,
|
||||
RuntimeTerminalWait,
|
||||
RuntimeWorktreeTerminalCloseResult
|
||||
RuntimeTerminalWait
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { shouldUseRendererBackedInteractiveTerminal } from '../codex-command-classification'
|
||||
import {
|
||||
formatTerminalClose,
|
||||
formatTerminalCreate,
|
||||
formatTerminalFocus,
|
||||
formatTerminalList,
|
||||
formatTerminalRead,
|
||||
formatTerminalRename,
|
||||
formatTerminalSend,
|
||||
formatTerminalShow,
|
||||
formatTerminalSplit,
|
||||
formatTerminalWait,
|
||||
reportCliError,
|
||||
printResult
|
||||
} from '../format'
|
||||
import {
|
||||
@@ -43,47 +37,14 @@ import {
|
||||
getRequiredWorktreeSelector,
|
||||
getTerminalHandle
|
||||
} from '../selectors'
|
||||
import { terminalCloseHandler } from './terminal-close'
|
||||
import { terminalSendHandler } from './terminal-send'
|
||||
|
||||
// Why: terminal wait legitimately needs to outlive the CLI's default RPC
|
||||
// timeout. Even without an explicit server timeout, the client must allow
|
||||
// long waits instead of failing at the generic 15s transport cap.
|
||||
const DEFAULT_TERMINAL_WAIT_RPC_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
/** A false stop receipt is an error only when the host supplied a liveness verdict. */
|
||||
function terminalCloseFailure(close: RuntimeTerminalClose): RuntimeClientError | null {
|
||||
if (close.ptyKilled || close.ptyStopVerdict === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const verdict = close.ptyStopVerdict
|
||||
const detail =
|
||||
verdict === 'live'
|
||||
? 'The PTY is live.'
|
||||
: `The PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its host could not be reached'}.`
|
||||
return new RuntimeClientError(
|
||||
verdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
|
||||
`Terminal ${close.handle} close failed to confirm the PTY stopped (${verdict}). ${detail}`,
|
||||
{ close }
|
||||
)
|
||||
}
|
||||
|
||||
function terminalCloseAllFailure(
|
||||
close: RuntimeWorktreeTerminalCloseResult
|
||||
): RuntimeClientError | null {
|
||||
if (!close.ptyStopVerdict) {
|
||||
return null
|
||||
}
|
||||
const detail =
|
||||
close.ptyStopVerdict === 'live'
|
||||
? 'At least one PTY is live.'
|
||||
: `At least one PTY was not confirmed stopped: ${close.ptyStopReason ?? 'its owning host could not be reached'}.`
|
||||
return new RuntimeClientError(
|
||||
close.ptyStopVerdict === 'live' ? 'terminal_stop_live' : 'terminal_stop_unverifiable',
|
||||
`Workspace terminal close did not confirm every PTY stopped (${close.ptyStopVerdict}). ${detail}`,
|
||||
{ close }
|
||||
)
|
||||
}
|
||||
|
||||
const terminalFocusHandler: CommandHandler = async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<{ focus: RuntimeTerminalFocus }>('terminal.focus', {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
@@ -147,23 +108,7 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
printResult(result, json, formatTerminalRead)
|
||||
},
|
||||
'terminal send': async ({ flags, client, cwd, json }) => {
|
||||
const text = getOptionalStringFlag(flags, 'text')
|
||||
const enter = flags.get('enter') === true
|
||||
const interrupt = flags.get('interrupt') === true
|
||||
const result = await client.call<{ send: RuntimeTerminalSend }>('terminal.send', {
|
||||
terminal: await getTerminalHandle(flags, cwd, client),
|
||||
text,
|
||||
enter,
|
||||
interrupt,
|
||||
...(text && enter && !interrupt ? { agentPrompt: true } : {}),
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
})
|
||||
printResult(result, json, formatTerminalSend)
|
||||
if (!result.result.send.accepted) {
|
||||
process.exitCode = 1
|
||||
}
|
||||
},
|
||||
'terminal send': terminalSendHandler,
|
||||
'terminal wait': async ({ flags, client, cwd, json }) => {
|
||||
const timeoutMs = getOptionalPositiveIntegerFlag(flags, 'timeout-ms')
|
||||
const result = await client.call<{ wait: RuntimeTerminalWait }>(
|
||||
@@ -223,67 +168,7 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
|
||||
'terminal switch': terminalFocusHandler,
|
||||
'terminal close': async ({ flags, client, cwd, json }) => {
|
||||
if (flags.get('all') === true) {
|
||||
if (flags.has('terminal') || flags.get('tab') === true) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'--all uses --worktree and cannot be combined with --terminal or --tab'
|
||||
)
|
||||
}
|
||||
try {
|
||||
const result = await client.call<RuntimeWorktreeTerminalCloseResult>('terminal.closeAll', {
|
||||
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client)
|
||||
})
|
||||
const failure = terminalCloseAllFailure(result.result)
|
||||
if (failure) {
|
||||
reportCliError(failure, json)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
printResult(
|
||||
result,
|
||||
json,
|
||||
(value) =>
|
||||
`Closed ${value.closed} terminal tabs and stopped ${value.stopped} terminal processes.`
|
||||
)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeClientError && error.code === 'method_not_found') {
|
||||
throw new RuntimeClientError(
|
||||
'incompatible_runtime',
|
||||
'This Orca host does not support closing every terminal in a workspace yet. Update Orca on the host and try again.'
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if (flags.has('worktree')) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Closing a workspace requires --all: terminal close --worktree <selector> --all'
|
||||
)
|
||||
}
|
||||
const method = flags.get('tab') === true ? 'terminal.closeTab' : 'terminal.close'
|
||||
const result = await client.call<{ close: RuntimeTerminalClose }>(method, {
|
||||
terminal: await getTerminalHandle(flags, cwd, client)
|
||||
})
|
||||
// Why: a transport-level success must not hide a live or unverifiable PTY. Keep the receipt in
|
||||
// error.data so JSON callers retain the host's exact evidence while receiving a failing outcome.
|
||||
const failure = terminalCloseFailure(result.result.close)
|
||||
if (failure) {
|
||||
// Keep the established human receipt (including its liveness warning); JSON needs the
|
||||
// standard failure envelope so callers do not mistake transport success for a stopped PTY.
|
||||
if (json) {
|
||||
reportCliError(failure, true)
|
||||
} else {
|
||||
printResult(result, false, formatTerminalClose)
|
||||
}
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
printResult(result, json, formatTerminalClose)
|
||||
},
|
||||
'terminal close': terminalCloseHandler,
|
||||
'terminal split': async ({ flags, client, cwd, json }) => {
|
||||
const directionFlag = getOptionalStringFlag(flags, 'direction')
|
||||
if (
|
||||
|
||||
+10
-2
@@ -1,6 +1,7 @@
|
||||
import type { CommandSpec } from './args'
|
||||
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
|
||||
import { unknownCommandData } from './command-suggestion'
|
||||
import { formatSkillsCommandFlagHelp } from './skills-command-flag-help'
|
||||
import { ROOT_HELP_TEXT_PRIMARY } from './root-help-text-primary'
|
||||
import { ROOT_HELP_TEXT_SECONDARY } from './root-help-text-secondary'
|
||||
|
||||
@@ -72,8 +73,9 @@ export function formatGroupHelp(specs: CommandSpec[], group: string): string {
|
||||
|
||||
function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
const command = commandPath.join(' ')
|
||||
if (command === 'skills install' && flag === 'agent') {
|
||||
return '--agent <names> Comma-separated install targets; default is detected agents'
|
||||
const skillsHelp = formatSkillsCommandFlagHelp(command, flag)
|
||||
if (skillsHelp) {
|
||||
return skillsHelp
|
||||
}
|
||||
if (command === 'terminal close' && flag === 'tab') {
|
||||
return '--tab Close the whole tab and wait for durable persistence'
|
||||
@@ -105,9 +107,15 @@ function formatCommandFlagHelp(flag: string, commandPath: string[]): string {
|
||||
if (command === 'orchestration worker-read' && flag === 'cursor') {
|
||||
return '--cursor <cursor> Opaque cursor returned by a previous worker-read page'
|
||||
}
|
||||
if (command === 'orchestration worker-list' && flag === 'cursor') {
|
||||
return '--cursor <cursor> Opaque page cursor copied from page.nextCursor'
|
||||
}
|
||||
if (command === 'orchestration worker-list' && flag === 'terminal-state') {
|
||||
return '--terminal-state <state> Terminal accounting filter: active, reclaimable, retained, release_pending, release_unknown, or released'
|
||||
}
|
||||
if (command === 'orchestration worker-list' && flag === 'include-remote') {
|
||||
return '--include-remote Include connected-server worker observations'
|
||||
}
|
||||
if (command === 'linear list-issues' && flag === 'workspace') {
|
||||
return '--workspace <id|all> Connected Linear workspace id, or all'
|
||||
}
|
||||
|
||||
@@ -228,6 +228,29 @@ describe('unknown command surfaces a suggestion', () => {
|
||||
expect(stderr).toContain('--json')
|
||||
})
|
||||
|
||||
it('names the offending --worktree value and the valid forms on selector_not_found', async () => {
|
||||
const { RuntimeRpcFailureError } = await import('./runtime/types.js')
|
||||
callMock.mockRejectedValue(
|
||||
new RuntimeRpcFailureError({
|
||||
id: 'req_selector',
|
||||
ok: false,
|
||||
error: { code: 'selector_not_found', message: 'selector_not_found' },
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
})
|
||||
)
|
||||
|
||||
await main(
|
||||
['orchestration', 'worker-start', '--task', 't1', '--worktree', 'repo-1', '--agent', 'codex'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
||||
expect(stderr).toContain('No Orca workspace matched the worktree selector "repo-1"')
|
||||
expect(stderr).toContain('id:repo-1::<absolute-path>')
|
||||
expect(stderr).toContain('Valid selector forms:')
|
||||
})
|
||||
|
||||
it('reports a pre-command flag that belongs to another command', async () => {
|
||||
await main(['--workspace', 'worktree', 'list'], '/tmp/repo')
|
||||
|
||||
@@ -305,6 +328,23 @@ describe('orca root help', () => {
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('labels retired coordinator scheduler commands at the root', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['--help'], '/tmp/repo')
|
||||
|
||||
const output = String(logSpy.mock.calls[0]?.[0])
|
||||
expect(output).toContain(
|
||||
'orchestration coordinator-start Retired: load the current orchestration skill'
|
||||
)
|
||||
expect(output).toContain(
|
||||
'orchestration coordinator-stop Retired: load the current orchestration skill'
|
||||
)
|
||||
expect(output).not.toContain('Start the legacy automatic coordinator loop')
|
||||
expect(output).not.toContain('Stop the legacy automatic coordinator loop')
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('advertises computer-use capabilities discovery', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -356,6 +396,7 @@ describe('orca root help', () => {
|
||||
expect(logSpy.mock.calls[0][0]).toContain(
|
||||
'orchestration worker-list Report worker terminal resource accounting'
|
||||
)
|
||||
expect(logSpy.mock.calls[0][0]).not.toContain('orchestration worker-cleanup')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -442,6 +483,21 @@ describe('orca root help', () => {
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('describes worker-list cursors as opaque page cursors', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
|
||||
await main(['orchestration', 'worker-list', '--help'], '/tmp/repo')
|
||||
|
||||
const help = String(logSpy.mock.calls[0][0])
|
||||
expect(help).toContain('[--cursor <cursor>]')
|
||||
expect(help).toContain('--cursor <cursor> Opaque page cursor copied from page.nextCursor')
|
||||
expect(help).toContain('Continue with the opaque page.nextCursor value unchanged.')
|
||||
expect(help).not.toContain('--cursor <dispatch_id>')
|
||||
expect(help).not.toContain('Line cursor from a previous read')
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('advertises Linear issue linking on worktree create and set help', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
logSpy.mockClear()
|
||||
|
||||
+5
-1
@@ -176,7 +176,11 @@ export async function main(
|
||||
json
|
||||
})
|
||||
} catch (error) {
|
||||
reportCliError(error, json, { commandPath: parsed.commandPath })
|
||||
const worktreeSelector = parsed.flags.get('worktree')
|
||||
reportCliError(error, json, {
|
||||
commandPath: parsed.commandPath,
|
||||
...(typeof worktreeSelector === 'string' ? { worktreeSelector } : {})
|
||||
})
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../shared/child-process/run-process'
|
||||
import {
|
||||
orchestrationMutationRecoveryError,
|
||||
renderCommand
|
||||
renderCommand,
|
||||
renderResolvedOrchestrationCommand
|
||||
} from './orchestration-mutation-recovery'
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
|
||||
@@ -212,6 +213,19 @@ describe('orchestration mutation recovery', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shell-quotes a configured Windows executable when resolving portable recovery commands', () => {
|
||||
expect(
|
||||
renderResolvedOrchestrationCommand(
|
||||
'orca orchestration worker-show --dispatch ctx_1 --json',
|
||||
'C:\\Program Files\\Orca\\orca-ide.cmd',
|
||||
'win32',
|
||||
{ ComSpec: 'C:\\Windows\\System32\\cmd.exe' }
|
||||
)
|
||||
).toBe(
|
||||
'"C:\\Program Files\\Orca\\orca-ide.cmd" "orchestration" "worker-show" "--dispatch" "ctx_1" "--json"'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps PowerShell and POSIX recovery guidance literal', () => {
|
||||
expect(
|
||||
renderCommand(['orca', 'literal "quoted" $HOME'], 'win32', {
|
||||
|
||||
@@ -167,6 +167,19 @@ export function renderCommand(
|
||||
return shell === 'powershell' && rendered ? `& ${rendered}` : rendered
|
||||
}
|
||||
|
||||
export function renderResolvedOrchestrationCommand(
|
||||
command: string,
|
||||
executable = resolveOrchestrationCliExecutable(),
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): string {
|
||||
const parts = parseCommandLine(command)
|
||||
if (parts?.[0] !== 'orca') {
|
||||
return command
|
||||
}
|
||||
return renderCommand([executable, ...parts.slice(1)], platform, env)
|
||||
}
|
||||
|
||||
function resolveRecoveryShell(
|
||||
platform: NodeJS.Platform,
|
||||
env: NodeJS.ProcessEnv
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { parseArgs } from './args'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
import { TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY } from '../shared/protocol-version'
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { TERMINAL_HANDLERS } from './handlers/terminal'
|
||||
import { ORCHESTRATION_HANDLERS } from './handlers/orchestration'
|
||||
import { readRetryRequestFlag } from './retry-request-flag'
|
||||
|
||||
const PATHS = COMMAND_SPECS.map((spec) => spec.path)
|
||||
|
||||
function promptClient() {
|
||||
const call = vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
send: {
|
||||
handle: 'term-1',
|
||||
accepted: true,
|
||||
bytesWritten: 2,
|
||||
prompt: {
|
||||
requestId: '11111111-1111-4111-8111-111111111111',
|
||||
stages: ['input_accepted', 'turn_started'],
|
||||
provider: 'claude',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 3
|
||||
}
|
||||
}
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
})
|
||||
const client = {
|
||||
call,
|
||||
getCliStatus: vi.fn().mockResolvedValue({
|
||||
result: {
|
||||
runtime: {
|
||||
reachable: true,
|
||||
runtimeId: 'runtime-1',
|
||||
capabilities: [TERMINAL_PROMPT_DELIVERY_RUNTIME_CAPABILITY]
|
||||
}
|
||||
}
|
||||
})
|
||||
} as unknown as RuntimeClient
|
||||
return { client, call }
|
||||
}
|
||||
|
||||
async function sendWith(
|
||||
argv: string[]
|
||||
): Promise<{ error: unknown; call: ReturnType<typeof vi.fn> }> {
|
||||
const { client, call } = promptClient()
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const error = await TERMINAL_HANDLERS['terminal send']({
|
||||
flags: parseArgs(argv, PATHS).flags,
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
return { error, call }
|
||||
}
|
||||
|
||||
describe('--retry-request and --wait-submit value damage', () => {
|
||||
it('parses a value-less flag as boolean true', () => {
|
||||
const parsed = parseArgs(
|
||||
['terminal', 'send', '--terminal', 'term-1', '--text', 'hi', '--enter', '--retry-request'],
|
||||
PATHS
|
||||
)
|
||||
expect(parsed.flags.get('retry-request')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a value-less --retry-request instead of minting a fresh identity', async () => {
|
||||
const { error, call } = await sendWith([
|
||||
'terminal',
|
||||
'send',
|
||||
'--terminal',
|
||||
'term-1',
|
||||
'--text',
|
||||
'hi',
|
||||
'--enter',
|
||||
'--retry-request'
|
||||
])
|
||||
expect(error).toMatchObject({ code: 'invalid_argument' })
|
||||
expect((error as Error).message).toContain('--retry-request requires a value')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects an empty --retry-request= value', async () => {
|
||||
const { error, call } = await sendWith([
|
||||
'terminal',
|
||||
'send',
|
||||
'--terminal',
|
||||
'term-1',
|
||||
'--text',
|
||||
'hi',
|
||||
'--enter',
|
||||
'--retry-request='
|
||||
])
|
||||
expect(error).toMatchObject({ code: 'invalid_argument' })
|
||||
expect((error as Error).message).toContain('--retry-request must be the UUID')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a non-UUID --retry-request value', () => {
|
||||
expect(() => readRetryRequestFlag(new Map([['retry-request', 'prompt-1']]))).toThrow(
|
||||
'--retry-request must be the UUID'
|
||||
)
|
||||
expect(
|
||||
readRetryRequestFlag(new Map([['retry-request', '11111111-1111-4111-8111-111111111111']]))
|
||||
).toBe('11111111-1111-4111-8111-111111111111')
|
||||
})
|
||||
|
||||
it('rejects a value-less --wait-submit instead of silently not waiting', async () => {
|
||||
const { error, call } = await sendWith([
|
||||
'terminal',
|
||||
'send',
|
||||
'--terminal',
|
||||
'term-1',
|
||||
'--text',
|
||||
'hi',
|
||||
'--enter',
|
||||
'--wait-submit'
|
||||
])
|
||||
expect(error).toMatchObject({ code: 'invalid_argument' })
|
||||
expect((error as Error).message).toContain('--wait-submit requires a value')
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a damaged --retry-request on an orchestration verb', async () => {
|
||||
const call = vi.fn()
|
||||
const client = { call } as unknown as RuntimeClient
|
||||
for (const value of [true as const, 'worker-stop-1']) {
|
||||
const error = await ORCHESTRATION_HANDLERS['orchestration worker-stop']({
|
||||
flags: new Map<string, string | boolean>([
|
||||
['dispatch', 'ctx_1'],
|
||||
['retry-request', value]
|
||||
]),
|
||||
client,
|
||||
cwd: '/tmp/worktree',
|
||||
json: true
|
||||
})
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
expect(error).toMatchObject({ code: 'invalid_argument' })
|
||||
}
|
||||
expect(call).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { rejectValuelessFlag } from './flags'
|
||||
import { RuntimeClientError } from './runtime/types'
|
||||
import {
|
||||
isOrchestrationRetryRequestId,
|
||||
RETRY_REQUEST_ID_GUIDANCE
|
||||
} from '../shared/orchestration-retry-request-id'
|
||||
|
||||
/**
|
||||
* `--retry-request` carries the mutation identity that makes a replay idempotent. A damaged value
|
||||
* must never fall through to `undefined`, because the client would then mint a fresh identity and
|
||||
* re-apply a mutation that may already have taken effect (#15180).
|
||||
*/
|
||||
export function readRetryRequestFlag(flags: Map<string, string | boolean>): string | undefined {
|
||||
const value = flags.get('retry-request')
|
||||
rejectValuelessFlag(value, 'retry-request')
|
||||
if (value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
if (!isOrchestrationRetryRequestId(value)) {
|
||||
throw new RuntimeClientError('invalid_argument', RETRY_REQUEST_ID_GUIDANCE)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -114,8 +114,8 @@ export const ROOT_HELP_TEXT_PRIMARY = [
|
||||
" orchestration worker-release Release a settled worker's terminal after archiving its output",
|
||||
' orchestration worker-retain Keep a worker terminal live for debugging',
|
||||
' orchestration worker-list Report worker terminal resource accounting',
|
||||
' orchestration coordinator-start Start the legacy automatic coordinator loop',
|
||||
' orchestration coordinator-stop Stop the legacy automatic coordinator loop',
|
||||
' orchestration coordinator-start Retired: load the current orchestration skill',
|
||||
' orchestration coordinator-stop Retired: load the current orchestration skill',
|
||||
' orchestration gate-create Create a decision gate blocking a task',
|
||||
' orchestration gate-resolve Resolve a pending decision gate',
|
||||
' orchestration gate-list List decision gates',
|
||||
|
||||
@@ -60,7 +60,7 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' orca terminal list [--worktree <selector>] [--limit <n>] [--include-visual-layouts] [--json]',
|
||||
' orca terminal show [--terminal <handle>] [--json]',
|
||||
' orca terminal read [--terminal <handle>] [--cursor <n>] [--limit <n>] [--json]',
|
||||
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
|
||||
' orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--wait-submit <seconds>] [--retry-request <id>] [--json]',
|
||||
' orca terminal wait [--terminal <handle>] --for exit|tui-idle [--timeout-ms <ms>] [--json]',
|
||||
' orca terminal create [--worktree <selector>] [--title <name>] [--command <text>] [--focus] [--json]',
|
||||
' orca terminal split [--terminal <handle>] [--direction horizontal|vertical] [--json]',
|
||||
@@ -90,6 +90,8 @@ export const ROOT_HELP_TEXT_SECONDARY = [
|
||||
' --text <text> Text to send to the terminal',
|
||||
' --enter Append Enter after sending text',
|
||||
' --interrupt Send as an interrupt-style input when supported',
|
||||
' --wait-submit <seconds> Observe this accepted prompt without resending it',
|
||||
' --retry-request <id> Resume the same durable prompt request after an ambiguous transport failure',
|
||||
'',
|
||||
'Terminal List Options:',
|
||||
' --include-visual-layouts Include tab and pane topology in JSON output',
|
||||
|
||||
@@ -84,11 +84,14 @@ describe('RuntimeClient module-graph deferral', () => {
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
// These eager modules must not pull the RuntimeClient dependency graph into help.
|
||||
// Why: the whole point of the change. These modules load on EVERY
|
||||
// invocation, so a value-import of the barrel from any of them drags the
|
||||
// RuntimeClient graph (zod, ws, tweetnacl) back onto the --help path.
|
||||
it.each([
|
||||
'args.ts',
|
||||
'flags.ts',
|
||||
'dispatch.ts',
|
||||
'format.ts',
|
||||
'cli-error.ts',
|
||||
'selectors.ts',
|
||||
'execution-host-flag.ts'
|
||||
@@ -100,7 +103,10 @@ describe('RuntimeClient module-graph deferral', () => {
|
||||
for (const line of valueImports) {
|
||||
expect(line, `${file}: "${line}" must be type-only`).toMatch(/^import type /)
|
||||
}
|
||||
expect(source).toContain("} from './runtime/types'")
|
||||
// Why: format.ts re-exports its error formatters; the guarded import lives in cli-error.ts.
|
||||
if (file !== 'format.ts') {
|
||||
expect(source).toContain("} from './runtime/types'")
|
||||
}
|
||||
})
|
||||
|
||||
it('index.ts has no eager value-import of the runtime client', () => {
|
||||
|
||||
@@ -2,16 +2,18 @@ import { createServer, type Server } from 'node:net'
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY } from '../../shared/protocol-version'
|
||||
import { ORCHESTRATION_WORKER_START_CLIENT_GRACE_MS } from '../../shared/orchestration-timing-budgets'
|
||||
import { MAX_TIMER_DELAY_MS } from '../../shared/timer-delay'
|
||||
import { orchestrationMutationRecoveryError } from '../orchestration-mutation-recovery'
|
||||
import { RuntimeClient, RuntimeRpcFailureError } from '../runtime-client'
|
||||
import { reportCliError } from '../format'
|
||||
import { RuntimeClient, RuntimeClientError, RuntimeRpcFailureError } from '../runtime-client'
|
||||
|
||||
const servers = new Set<Server>()
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await Promise.all(
|
||||
[...servers].map(
|
||||
(server) =>
|
||||
@@ -23,6 +25,37 @@ afterEach(async () => {
|
||||
servers.clear()
|
||||
})
|
||||
|
||||
function writeRuntimeConnection(userDataPath: string, endpoint: string, runtimeId: string): void {
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId,
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
function expectPromptRetryBlockedJson(error: unknown, requestId: string): void {
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
reportCliError(error, true)
|
||||
const output = JSON.parse(String(log.mock.calls[0]?.[0])) as {
|
||||
error: { data?: Record<string, unknown> }
|
||||
}
|
||||
expect(output.error.data).toMatchObject({
|
||||
deliveryOutcome: 'unknown',
|
||||
retrySafe: false,
|
||||
nextSteps: expect.arrayContaining([
|
||||
'Inspect the terminal output and agent state without sending input.'
|
||||
])
|
||||
})
|
||||
expect(JSON.stringify(output)).not.toContain('--retry-request')
|
||||
expect(JSON.stringify(output)).not.toContain(requestId)
|
||||
expect(output.error.data).not.toHaveProperty('orchestrationRequestId')
|
||||
}
|
||||
|
||||
describe('RuntimeClient orchestration recovery identity', () => {
|
||||
it('rejects a worker-start timeout whose client grace would overflow timers', () => {
|
||||
const client = new RuntimeClient(undefined, 60_000, null, null, 'orca')
|
||||
@@ -74,16 +107,7 @@ describe('RuntimeClient orchestration recovery identity', () => {
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-runtime.json'),
|
||||
JSON.stringify({
|
||||
runtimeId: 'runtime-1',
|
||||
pid: 1,
|
||||
transports: [{ kind: 'unix', endpoint }],
|
||||
authToken: 'token',
|
||||
startedAt: 1
|
||||
})
|
||||
)
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-1')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
try {
|
||||
@@ -127,4 +151,233 @@ describe('RuntimeClient orchestration recovery identity', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps durable prompt retry when failure metadata proves the preflight runtime', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-current-prompt-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as { id: string }
|
||||
socket.end(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: { code: 'runtime_timeout', message: 'request timed out' },
|
||||
_meta: { runtimeId: 'runtime-current' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-current')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-current',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: 'prompt-current'
|
||||
}
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expect(error).toMatchObject({
|
||||
data: { orchestrationRequestId: 'prompt-current' }
|
||||
})
|
||||
expect((error as Error).message).toContain('--retry-request prompt-current')
|
||||
})
|
||||
|
||||
it('keeps the prompt retry ID when the attested runtime times out in transport', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-rt-timeout-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-current')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 200, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-current',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-current' },
|
||||
orchestrationRequestId: 'prompt-transport-timeout'
|
||||
}
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(receivedRequest?.orchestrationRequestId).toBe('prompt-transport-timeout')
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect(error).not.toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expect((error as RuntimeClientError).code).toBe('runtime_timeout')
|
||||
expect(error).toMatchObject({ data: { orchestrationRequestId: 'prompt-transport-timeout' } })
|
||||
expect((error as Error).message).toContain(
|
||||
'--retry-request prompt-transport-timeout --wait-submit <seconds>'
|
||||
)
|
||||
expect((error as RuntimeClientError).data).not.toHaveProperty('retrySafe')
|
||||
})
|
||||
|
||||
it('blocks retry when a downgraded runtime rejects after capability preflight', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-downgraded-prompt-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
receivedRequest = request
|
||||
socket.end(
|
||||
`${JSON.stringify({
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: { code: 'runtime_timeout', message: 'request timed out' },
|
||||
_meta: { runtimeId: 'runtime-after-downgrade' }
|
||||
})}\n`
|
||||
)
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-after-downgrade')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-downgraded',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-before-downgrade' },
|
||||
orchestrationRequestId: 'prompt-downgraded-rejection'
|
||||
}
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(receivedRequest?.orchestrationRequestId).toBe('prompt-downgraded-rejection')
|
||||
expect(error).toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expectPromptRetryBlockedJson(error, 'prompt-downgraded-rejection')
|
||||
})
|
||||
|
||||
it('blocks retry when a downgraded runtime loses the prompt reply', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-lost-prompt-reply-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
const request = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
receivedRequest = request
|
||||
socket.destroy()
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-after-downgrade')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-downgraded',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{
|
||||
terminalPromptPreflight: { runtimeId: 'runtime-before-downgrade' },
|
||||
orchestrationRequestId: 'prompt-downgraded-lost-reply'
|
||||
}
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(receivedRequest?.orchestrationRequestId).toBe('prompt-downgraded-lost-reply')
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect(error).not.toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expectPromptRetryBlockedJson(error, 'prompt-downgraded-lost-reply')
|
||||
expect(JSON.stringify((error as RuntimeClientError).data)).not.toContain('Update Orca')
|
||||
})
|
||||
|
||||
it('reports an unknown legacy prompt outcome without advertising an unsafe retry', async () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-legacy-prompt-'))
|
||||
const endpoint = join(userDataPath, 'runtime.sock')
|
||||
let receivedRequest: Record<string, unknown> | undefined
|
||||
const server = createServer((socket) => {
|
||||
socket.once('data', (data) => {
|
||||
receivedRequest = JSON.parse(String(data).trim()) as Record<string, unknown>
|
||||
socket.destroy()
|
||||
})
|
||||
})
|
||||
servers.add(server)
|
||||
await new Promise<void>((resolve) => server.listen(endpoint, resolve))
|
||||
writeRuntimeConnection(userDataPath, endpoint, 'runtime-legacy')
|
||||
|
||||
const client = new RuntimeClient(userDataPath, 500, null, null, 'orca')
|
||||
const error = await client
|
||||
.call(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-legacy',
|
||||
text: 'review',
|
||||
enter: true,
|
||||
interrupt: false,
|
||||
agentPrompt: true,
|
||||
client: { id: 'orca-cli', type: 'desktop' }
|
||||
},
|
||||
{ legacyTerminalPrompt: true }
|
||||
)
|
||||
.then(() => undefined)
|
||||
.catch((caught: unknown) => caught)
|
||||
|
||||
expect(error).toBeInstanceOf(RuntimeClientError)
|
||||
expect(error).not.toBeInstanceOf(RuntimeRpcFailureError)
|
||||
expect(error).toMatchObject({
|
||||
data: {
|
||||
deliveryOutcome: 'unknown',
|
||||
retrySafe: false,
|
||||
nextSteps: expect.arrayContaining([
|
||||
'Inspect the terminal output and agent state without sending input.',
|
||||
'Update Orca on the execution host before future prompt sends that need durable retry.'
|
||||
])
|
||||
}
|
||||
})
|
||||
expect((error as RuntimeClientError).data).not.toHaveProperty('orchestrationRequestId')
|
||||
expect(receivedRequest).not.toHaveProperty('orchestrationRequestId')
|
||||
expect((error as Error).message).not.toContain('--retry-request')
|
||||
expect((error as Error).message).toContain('do not resend automatically')
|
||||
})
|
||||
})
|
||||
|
||||
+44
-60
@@ -3,17 +3,25 @@ import type { CliStatusResult, RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { runtimeHostConnectionState } from '../../shared/runtime-host-connection-state'
|
||||
import type { RuntimeOrchestrationEnvelope } from '../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
isDurableMutation,
|
||||
isOrchestrationMutation,
|
||||
isTerminalPromptMutation,
|
||||
orchestrationMigrationData
|
||||
} from '../../shared/orchestration-rpc-contract'
|
||||
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
|
||||
import type { PairingOffer } from '../../shared/pairing'
|
||||
import { launchOrcaApp } from './launch'
|
||||
import { getDefaultUserDataPath, readMetadata } from './metadata'
|
||||
import { getCliStatus, projectRemoteAppStatus } from './status'
|
||||
import { sendRequest } from './transport'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types'
|
||||
import { attachMutationRecovery } from './client-error-recovery'
|
||||
import { markEnvironmentUsed, resolveEnvironmentPairingOffer } from './environments'
|
||||
import {
|
||||
attachDurableMutationRecovery,
|
||||
attachLegacyTerminalPromptRecovery,
|
||||
attachUnverifiedTerminalPromptRecovery,
|
||||
didAnotherRuntimeHandleTerminalPrompt
|
||||
} from './terminal-prompt-mutation-recovery'
|
||||
import { markEnvironmentUsed } from './environments'
|
||||
import { resolveRemotePairing } from './runtime-remote-pairing'
|
||||
import {
|
||||
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
|
||||
ORCHESTRATION_CONTRACT_VERSION
|
||||
@@ -32,20 +40,9 @@ import {
|
||||
resolveOrchestrationCliExecutable
|
||||
} from './orchestration-recovery-command'
|
||||
|
||||
// Why: for long-poll methods the caller's method-level
|
||||
// `params.timeoutMs` is the inner waiter budget; we extend the client-side
|
||||
// socket timeout to `timeoutMs + GRACE_MS` so the client's own idle timer
|
||||
// never fires before the server-side waiter has had a chance to resolve and
|
||||
// emit its terminal frame. The 10 s grace absorbs round-trip + one final
|
||||
// keepalive window. See design doc §3.1.
|
||||
const LONG_POLL_CLIENT_GRACE_MS = 10_000
|
||||
|
||||
// Why: ws + tweetnacl + the remote-runtime frame stack only matter once a
|
||||
// request actually goes over a pairing offer, which local CLI calls never do.
|
||||
// Both call sites already await this, so deferring the load changes no ordering.
|
||||
async function loadWebSocketTransport() {
|
||||
return await import('./websocket-transport.js')
|
||||
}
|
||||
const loadWebSocketTransport = async () => await import('./websocket-transport.js')
|
||||
|
||||
export class RuntimeClient {
|
||||
private readonly userDataPath: string
|
||||
@@ -87,19 +84,43 @@ export class RuntimeClient {
|
||||
async call<TResult>(
|
||||
method: string,
|
||||
params?: unknown,
|
||||
options?: { timeoutMs?: number } & RuntimeOrchestrationEnvelope
|
||||
options?: {
|
||||
timeoutMs?: number
|
||||
legacyTerminalPrompt?: true
|
||||
terminalPromptPreflight?: { runtimeId: string | null }
|
||||
} & RuntimeOrchestrationEnvelope
|
||||
): Promise<RuntimeRpcSuccess<TResult>> {
|
||||
const effectiveTimeoutMs = options?.timeoutMs ?? this.resolveMethodTimeoutMs(method, params)
|
||||
const orchestrationMutation = isOrchestrationMutation(method, params)
|
||||
const terminalPromptMutation = isTerminalPromptMutation(method, params)
|
||||
const legacyTerminalPrompt = options?.legacyTerminalPrompt === true && terminalPromptMutation
|
||||
const durableMutation = !legacyTerminalPrompt && isDurableMutation(method, params)
|
||||
if (orchestrationMutation) {
|
||||
await this.ensureOrchestrationContractCompatible(effectiveTimeoutMs)
|
||||
}
|
||||
const orchestrationRequestId = orchestrationMutation
|
||||
const orchestrationRequestId = durableMutation
|
||||
? (options?.orchestrationRequestId ?? randomUUID())
|
||||
: undefined
|
||||
const originalCommand = orchestrationMutation
|
||||
const originalCommand = durableMutation
|
||||
? buildOrchestrationRecoveryCommand(method, params, this.cliExecutable, this.originalArgs)
|
||||
: undefined
|
||||
const recover = (error: unknown, targetRuntimeId: string | null) => {
|
||||
if (legacyTerminalPrompt) {
|
||||
return attachLegacyTerminalPromptRecovery(error)
|
||||
}
|
||||
if (
|
||||
terminalPromptMutation &&
|
||||
options?.terminalPromptPreflight &&
|
||||
didAnotherRuntimeHandleTerminalPrompt(
|
||||
error,
|
||||
options.terminalPromptPreflight.runtimeId,
|
||||
targetRuntimeId
|
||||
)
|
||||
) {
|
||||
return attachUnverifiedTerminalPromptRecovery(error)
|
||||
}
|
||||
return attachDurableMutationRecovery(error, orchestrationRequestId, originalCommand, method)
|
||||
}
|
||||
const compatibilityEnvelope = method.startsWith('orchestration.')
|
||||
? {
|
||||
...this.orchestrationCompatibility,
|
||||
@@ -128,14 +149,10 @@ export class RuntimeClient {
|
||||
envelope
|
||||
})
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
throw recover(error, null)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
throw recover(new RuntimeRpcFailureError(response), null)
|
||||
}
|
||||
if (this.environmentSelector) {
|
||||
markEnvironmentUsed(this.userDataPath, this.environmentSelector, {
|
||||
@@ -149,14 +166,10 @@ export class RuntimeClient {
|
||||
try {
|
||||
response = await sendRequest<TResult>(metadata, method, params, effectiveTimeoutMs, envelope)
|
||||
} catch (error) {
|
||||
throw attachMutationRecovery(error, orchestrationRequestId, originalCommand)
|
||||
throw recover(error, metadata.runtimeId ?? null)
|
||||
}
|
||||
if (response.ok === false) {
|
||||
throw attachMutationRecovery(
|
||||
new RuntimeRpcFailureError(response),
|
||||
orchestrationRequestId,
|
||||
originalCommand
|
||||
)
|
||||
throw recover(new RuntimeRpcFailureError(response), metadata.runtimeId ?? null)
|
||||
}
|
||||
return response
|
||||
}
|
||||
@@ -293,33 +306,4 @@ function throwDesktopActivationBlocked(): never {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveRemotePairing(
|
||||
userDataPath: string,
|
||||
pairingCode: string | null,
|
||||
environmentSelector: string | null
|
||||
): PairingOffer | null {
|
||||
if (pairingCode && environmentSelector) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --pairing-code or --environment, not both.'
|
||||
)
|
||||
}
|
||||
if (environmentSelector) {
|
||||
return resolveEnvironmentPairingOffer(userDataPath, environmentSelector)
|
||||
}
|
||||
if (!pairingCode) {
|
||||
return null
|
||||
}
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
if (!pairing) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
return pairing
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
const delay = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { parsePairingCode, type PairingOffer } from '../../shared/pairing'
|
||||
import { resolveEnvironmentPairingOffer } from './environments'
|
||||
import { RuntimeClientError } from './types'
|
||||
|
||||
export function resolveRemotePairing(
|
||||
userDataPath: string,
|
||||
pairingCode: string | null,
|
||||
environmentSelector: string | null
|
||||
): PairingOffer | null {
|
||||
if (pairingCode && environmentSelector) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Use either --pairing-code or --environment, not both.'
|
||||
)
|
||||
}
|
||||
if (environmentSelector) {
|
||||
return resolveEnvironmentPairingOffer(userDataPath, environmentSelector)
|
||||
}
|
||||
if (!pairingCode) {
|
||||
return null
|
||||
}
|
||||
const pairing = parsePairingCode(pairingCode)
|
||||
if (!pairing) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
'Invalid remote pairing code. Expected an orca://pair?... URL or bare pairing payload.'
|
||||
)
|
||||
}
|
||||
return pairing
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { attachMutationRecovery } from './client-error-recovery'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './types'
|
||||
|
||||
const INSPECT_STEP = 'Inspect the terminal output and agent state without sending input.'
|
||||
|
||||
export function attachDurableMutationRecovery(
|
||||
error: unknown,
|
||||
requestId: string | undefined,
|
||||
originalCommand: string[] | undefined,
|
||||
method: string
|
||||
): unknown {
|
||||
if (method !== 'terminal.send' || !requestId || !(error instanceof RuntimeClientError)) {
|
||||
return attachMutationRecovery(error, requestId, originalCommand)
|
||||
}
|
||||
const message = `${error.message} Terminal prompt request ID: ${requestId}. Re-issue the exact command with --retry-request ${requestId} --wait-submit <seconds>; do not retry it without that ID.`
|
||||
const data = {
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
orchestrationRequestId: requestId,
|
||||
...(originalCommand ? { originalCommand } : {})
|
||||
}
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return new RuntimeRpcFailureError({
|
||||
...error.response,
|
||||
error: { ...error.response.error, message, data }
|
||||
})
|
||||
}
|
||||
return new RuntimeClientError(error.code, message, data)
|
||||
}
|
||||
|
||||
export function attachLegacyTerminalPromptRecovery(error: unknown): unknown {
|
||||
if (!(error instanceof RuntimeClientError)) {
|
||||
return error
|
||||
}
|
||||
return attachUnknownTerminalPromptRecovery(
|
||||
error,
|
||||
'The legacy host cannot prove whether the prompt was delivered',
|
||||
[
|
||||
INSPECT_STEP,
|
||||
'Update Orca on the execution host before future prompt sends that need durable retry.'
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
export function attachUnverifiedTerminalPromptRecovery(error: unknown): RuntimeClientError {
|
||||
const normalized =
|
||||
error instanceof RuntimeClientError
|
||||
? error
|
||||
: new RuntimeClientError(
|
||||
'runtime_error',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
return attachUnknownTerminalPromptRecovery(
|
||||
normalized,
|
||||
'Orca cannot prove whether the prompt was delivered by the prompt-delivery-capable runtime from the preflight',
|
||||
[
|
||||
INSPECT_STEP,
|
||||
'A different Orca runtime answered than the one whose prompt-delivery support was verified; confirm which runtime serves this host before sending again.'
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The prompt request ID survives a failed send unless a runtime other than the preflight's
|
||||
* prompt-delivery host handled it; a transport failure alone means nobody else answered, and the
|
||||
* attested host still holds the durable pending receipt that makes `--retry-request` idempotent.
|
||||
*/
|
||||
export function didAnotherRuntimeHandleTerminalPrompt(
|
||||
error: unknown,
|
||||
preflightRuntimeId: string | null,
|
||||
targetRuntimeId: string | null
|
||||
): boolean {
|
||||
const handledBy =
|
||||
error instanceof RuntimeRpcFailureError
|
||||
? (error.response._meta?.runtimeId ?? null)
|
||||
: targetRuntimeId
|
||||
if (handledBy === null) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
typeof preflightRuntimeId !== 'string' ||
|
||||
preflightRuntimeId.length === 0 ||
|
||||
handledBy !== preflightRuntimeId
|
||||
)
|
||||
}
|
||||
|
||||
function attachUnknownTerminalPromptRecovery(
|
||||
error: RuntimeClientError,
|
||||
reason: string,
|
||||
nextSteps: string[]
|
||||
): RuntimeClientError {
|
||||
const message = `${error.message} ${reason}; inspect the terminal before deciding what to do, and do not resend automatically.`
|
||||
const data: Record<string, unknown> = {
|
||||
...(error.data && typeof error.data === 'object' ? error.data : {}),
|
||||
deliveryOutcome: 'unknown',
|
||||
retrySafe: false,
|
||||
nextSteps
|
||||
}
|
||||
delete data.orchestrationRequestId
|
||||
delete data.originalCommand
|
||||
delete data.recovery
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return new RuntimeRpcFailureError({
|
||||
...error.response,
|
||||
error: { ...error.response.error, message, data }
|
||||
})
|
||||
}
|
||||
return new RuntimeClientError(error.code, message, data)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Per-flag help for the skills commands, kept out of the shared help chain it would crowd. */
|
||||
const SKILLS_FLAG_HELP: Record<string, Record<string, string>> = {
|
||||
'skills get': {
|
||||
full: '--full Print the full guide with bundled references',
|
||||
reference: '--reference <name> Print one bundled reference by name',
|
||||
references: '--references List the bundled reference names for a topic'
|
||||
},
|
||||
'skills install': {
|
||||
agent: '--agent <names> Comma-separated install targets; default is detected agents'
|
||||
}
|
||||
}
|
||||
|
||||
export function formatSkillsCommandFlagHelp(command: string, flag: string): string | undefined {
|
||||
return SKILLS_FLAG_HELP[command]?.[flag]
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
||||
|
||||
vi.mock('./bundled-skill-guides.js', () => ({
|
||||
BUNDLED_SKILL_GUIDES: [
|
||||
{
|
||||
name: 'alpha',
|
||||
description: 'Use when alpha work is needed.',
|
||||
markdown: '# Alpha\n\nShort.\n',
|
||||
fullMarkdown: '# Alpha\n\nShort.\n\n## References\n\nFull.\n',
|
||||
aliases: ['legacy-alpha'],
|
||||
references: [
|
||||
{ name: 'first-gate', markdown: '# First gate\n\nDo the first thing.\n' },
|
||||
{ name: 'second-gate', markdown: '# Second gate\n\nDo the second thing.\n' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'zeta',
|
||||
description: 'Use when zeta work is needed.',
|
||||
markdown: '# Zeta\n',
|
||||
fullMarkdown: '# Zeta\n',
|
||||
aliases: [],
|
||||
references: []
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
vi.mock('./runtime-client', async () => {
|
||||
const { RuntimeClientError, RuntimeRpcFailureError } = await import('./runtime/types.js')
|
||||
class RuntimeClient {
|
||||
constructor() {
|
||||
throw new Error('skills get constructed a RuntimeClient')
|
||||
}
|
||||
}
|
||||
return {
|
||||
RuntimeClient,
|
||||
RuntimeClientError,
|
||||
RuntimeRpcFailureError,
|
||||
serveOrcaApp: vi.fn(),
|
||||
getDefaultUserDataPath: vi.fn(() => '/tmp/orca-user-data')
|
||||
}
|
||||
})
|
||||
|
||||
import { main } from './index'
|
||||
|
||||
function stdoutText(spy: ReturnType<typeof vi.spyOn>): string {
|
||||
return spy.mock.calls.map((call) => String(call[0])).join('')
|
||||
}
|
||||
|
||||
describe('orca skills get --reference', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
process.exitCode = undefined
|
||||
})
|
||||
|
||||
it('prints only the named reference, with no kernel and no header', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--reference', 'second-gate'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('# Second gate\n\nDo the second thing.\n')
|
||||
})
|
||||
|
||||
it('accepts the references/<file>.md spelling the gate table prints', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--reference', 'references/first-gate.md'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('# First gate\n\nDo the first thing.\n')
|
||||
})
|
||||
|
||||
it('resolves a reference through a topic alias', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'legacy-alpha', '--reference', 'first-gate.md'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('# First gate\n\nDo the first thing.\n')
|
||||
})
|
||||
|
||||
it('gives --reference --json the canonical topic, reference name, and Markdown', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(
|
||||
['skills', 'get', 'legacy-alpha', '--reference', 'references/first-gate.md', '--json'],
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: 'alpha',
|
||||
reference: 'first-gate',
|
||||
markdown: '# First gate\n\nDo the first thing.\n'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('lists reference names for --references', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--references'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe('first-gate\nsecond-gate\n')
|
||||
})
|
||||
|
||||
it('gives --references --json a stable schema', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--references', '--json'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
`${JSON.stringify({ name: 'alpha', references: ['first-gate', 'second-gate'] }, null, 2)}\n`
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a topic that ships no references', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'zeta', '--references'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Guide "zeta" has no bundled references.')
|
||||
})
|
||||
|
||||
it('names the available references for an unknown one', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--reference', 'nope'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith(
|
||||
'Unknown reference "nope" for alpha. Available: first-gate, second-gate'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects --reference on a topic with no references', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'zeta', '--reference', 'first-gate'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Guide "zeta" has no bundled references.')
|
||||
})
|
||||
|
||||
it('rejects --reference without a value', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--reference'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Missing required --reference')
|
||||
})
|
||||
|
||||
it('rejects combining --full with --reference', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--full', '--reference', 'first-gate'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Use either --full or --reference, not both.')
|
||||
})
|
||||
|
||||
it('rejects combining --references with --full or --reference', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await main(['skills', 'get', 'alpha', '--references', '--full'], '/tmp/repo')
|
||||
await main(['skills', 'get', 'alpha', '--references', '--reference', 'first-gate'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenNthCalledWith(1, 'Use either --references or --full, not both.')
|
||||
expect(errorSpy).toHaveBeenNthCalledWith(2, 'Use either --references or --reference, not both.')
|
||||
})
|
||||
|
||||
it('still serves the kernel and the full package unchanged', async () => {
|
||||
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
|
||||
|
||||
await main(['skills', 'get', 'alpha'], '/tmp/repo')
|
||||
await main(['skills', 'get', 'alpha', '--full'], '/tmp/repo')
|
||||
|
||||
expect(stdoutText(stdoutSpy)).toBe(
|
||||
'# Alpha\n\nShort.\n# Alpha\n\nShort.\n\n## References\n\nFull.\n'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -213,7 +213,7 @@ describe('orca skills CLI', () => {
|
||||
await main(['--help'], '/tmp/repo')
|
||||
|
||||
expect(String(logSpy.mock.calls[0]?.[0])).toContain(
|
||||
'Usage: orca skills get <topic> [--full] [--json]'
|
||||
'Usage: orca skills get <topic> [--full | --reference <name>] [--json]'
|
||||
)
|
||||
expect(String(logSpy.mock.calls[1]?.[0])).toContain(
|
||||
'Commands:\n installed List installed skill selectors'
|
||||
@@ -303,7 +303,7 @@ describe('orca skills CLI', () => {
|
||||
await main(['skills', 'install', '--skill'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(errorSpy).toHaveBeenCalledWith('Missing required --skill')
|
||||
expect(errorSpy).toHaveBeenCalledWith('--skill requires a value; it was passed with none.')
|
||||
expect(spawnMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
import { WORKTREE_LISTING_SCOPE_NOTES } from './worktree-listing-scope-notes'
|
||||
import { SERVE_COMMAND_SPECS } from './serve'
|
||||
import { TERMINAL_SEND_COMMAND_SPEC } from './terminal-send'
|
||||
import { TERMINAL_CLOSE_COMMAND_SPEC } from './terminal-close'
|
||||
|
||||
export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
@@ -224,13 +225,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'orca terminal read --terminal term_abc123 --screen --json'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'send'],
|
||||
summary: 'Send input to a live terminal',
|
||||
usage:
|
||||
'orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal', 'text', 'enter', 'interrupt']
|
||||
},
|
||||
TERMINAL_SEND_COMMAND_SPEC,
|
||||
{
|
||||
path: ['terminal', 'wait'],
|
||||
summary: 'Wait for a terminal condition',
|
||||
|
||||
@@ -5,10 +5,14 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'worker-start'],
|
||||
summary: 'Start one supervised worker on the Run home or a connected Orca server',
|
||||
usage:
|
||||
'orca orchestration worker-start --task <task_id> [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--model <id>] [--effort <level>] [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
'orca orchestration worker-start (--task <task_id> | --spec <text>) [--on <saved-environment>] [--worktree <current|selector|new-child|new-top-level>] (--agent <agent> | --terminal <handle>) [--task-title <text>] [--deps <json_array>] [--parent <task_id>] [--model <id>] [--effort <level>] [--name <name>] [--repo <selector>] [--base-branch <ref>] [--display-name <text>] [--comment <text>] [--setup <run|skip|inherit>] [--retry-of <dispatch_id>] [--timeout-ms <n>] [--run <run_id>] [--from <handle>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'task',
|
||||
'spec',
|
||||
'task-title',
|
||||
'deps',
|
||||
'parent',
|
||||
'on',
|
||||
'worktree',
|
||||
'name',
|
||||
@@ -35,7 +39,7 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
'Creation flags (--name, --repo, --base-branch, --display-name, --comment, --setup) are rejected for current/existing worktrees. Use exact --repo on the selected server; project/host convenience routing remains on worktree create.',
|
||||
'--on selects only the worker server; the Run and this command remain on the current Orca server.',
|
||||
'Remote current and new-child are invalid; discover an exact remote selector or use new-top-level.',
|
||||
'--retry-of links the replacement attempt but does not inherit placement; repeat the intended --on/worktree and --agent/terminal choices.',
|
||||
'--retry-of needs --task naming the failed Task (--spec creates a new one) and does not inherit placement; repeat the intended --on/worktree and --agent/terminal choices.',
|
||||
'The call exits 0 only for ready. Failed or outcome_unknown exits 1 and JSON includes stage/failedStage, setup, effects, residualResources, and recovery commands when needed.'
|
||||
]
|
||||
},
|
||||
@@ -110,11 +114,13 @@ export const ORCHESTRATION_WORKER_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['orchestration', 'worker-list'],
|
||||
summary: 'List supervised worker terminal resource accounting',
|
||||
usage:
|
||||
'orca orchestration worker-list [--run <run_id>] [--terminal-state <active|reclaimable|retained|release_pending|release_unknown|released>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'run', 'terminal-state'],
|
||||
'orca orchestration worker-list [--run <run_id>] [--terminal-state <active|reclaimable|retained|release_pending|release_unknown|released>] [--include-remote] [--cursor <cursor>] [--limit <1-100>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'run', 'terminal-state', 'include-remote', 'cursor', 'limit'],
|
||||
notes: [
|
||||
'Terminal state is process accounting and is reported separately from Task status; a completed Task can still own a live terminal.',
|
||||
'Context-only Dispatches created by orchestration dispatch are included as unsupervised with terminal state retained.'
|
||||
'Context-only Dispatches created by orchestration dispatch are included as unsupervised with terminal state retained.',
|
||||
'Returns at most 100 local rows by default; --include-remote adds connected-server observations when the host supports fleet listing. Continue with the opaque page.nextCursor value unchanged.',
|
||||
'Without --run the list is scoped to the Run bound to the calling terminal, and to every Run when there is no binding; the receipt reports which in scope.source (flag, bound, or all).'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -15,3 +15,17 @@ describe('orchestration send command spec', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orchestration check command spec', () => {
|
||||
it('documents --types as a wake condition rather than a batch filter', () => {
|
||||
const checkSpec = ORCHESTRATION_COMMAND_SPECS.find(
|
||||
(spec) => spec.path.join(' ') === 'orchestration check'
|
||||
)
|
||||
|
||||
expect(checkSpec?.notes).toEqual(
|
||||
expect.arrayContaining([
|
||||
'--types is the wake condition for --wait; a returned Delivery is always the whole FIFO batch, so it is never filtered by type. Only --peek and --all filter their rows.'
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,6 +109,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
|
||||
],
|
||||
notes: [
|
||||
'On Windows PowerShell, quote comma-separated type filters, e.g. --types "worker_done,escalation".',
|
||||
'--types is the wake condition for --wait; a returned Delivery is always the whole FIFO batch, so it is never filtered by type. Only --peek and --all filter their rows.',
|
||||
'--format renders the returned rows as local text only; it never writes to another terminal.',
|
||||
'A bound Run replays the same Delivery until --ack; process every message before acknowledging.'
|
||||
]
|
||||
|
||||
@@ -12,6 +12,26 @@ function spec(path: string): (typeof SKILL_COMMAND_SPECS)[number] {
|
||||
}
|
||||
|
||||
describe('skill command specs', () => {
|
||||
it('describes compact retrieval as the default and --full as the full guide', () => {
|
||||
const help = formatCommandHelp(spec('skills get'))
|
||||
|
||||
expect(help).toContain('Prints the compact guide by default')
|
||||
expect(help).toContain('--full Print the full guide with bundled references')
|
||||
expect(help).not.toContain('--full Include all supported V1 issue context')
|
||||
})
|
||||
|
||||
it('documents the per-reference selector beside --full', () => {
|
||||
const help = formatCommandHelp(spec('skills get'))
|
||||
|
||||
expect(help).toContain('Usage: orca skills get <topic> [--full | --reference <name>] [--json]')
|
||||
expect(help).toContain('--reference <name> Print one bundled reference by name')
|
||||
expect(help).toContain('--references List the bundled reference names for a topic')
|
||||
expect(help).toContain('orca skills get orchestration --reference recovery-and-cleanup')
|
||||
expect(effectiveAllowedFlags(spec('skills get'))).toEqual(
|
||||
expect.arrayContaining(['reference', 'references'])
|
||||
)
|
||||
})
|
||||
|
||||
it('requires explicit selectors for sharing and exposes no bulk or path flag', () => {
|
||||
const flags = effectiveAllowedFlags(spec('skills share'))
|
||||
|
||||
|
||||
+12
-5
@@ -40,22 +40,29 @@ export const SKILL_COMMAND_SPECS: CommandSpec[] = [
|
||||
notes: [
|
||||
'Reads bundled guide metadata locally without contacting the Orca runtime.',
|
||||
'With --json, prints a topics array of canonical names and one-line descriptions.',
|
||||
'Use `orca skills get <name>` for the full guide, or `orca skills install` to install skills.'
|
||||
'Use `orca skills get <name>` for the compact guide, `--full` for its full reference package, or `orca skills install` to install skills.'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['skills', 'get'],
|
||||
aliases: [['skills', 'show']],
|
||||
summary: 'Print a version-matched skill guide as Markdown',
|
||||
usage: 'orca skills get <topic> [--full] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'topic', 'full'],
|
||||
usage: 'orca skills get <topic> [--full | --reference <name>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'topic', 'full', 'reference', 'references'],
|
||||
positionalArgs: ['topic'],
|
||||
notes: [
|
||||
'Reads bundled guide content locally without contacting the Orca runtime.',
|
||||
'Use --full to include bundled reference documents when the guide provides them.',
|
||||
'Prints the compact guide by default. Use --full to print the full guide with bundled references when provided.',
|
||||
'Use --reference <name> to print one bundled reference alone, which is what an action gate in the compact guide needs; --references lists the available names.',
|
||||
'A reference name may be given bare (recovery-and-cleanup) or as the guide spells it (references/recovery-and-cleanup.md).',
|
||||
'Use --json for a deterministic object containing canonical topic metadata and content.'
|
||||
],
|
||||
examples: ['orca skills get orca-cli', 'orca skills get orchestration --full']
|
||||
examples: [
|
||||
'orca skills get orca-cli',
|
||||
'orca skills get orchestration --full',
|
||||
'orca skills get orchestration --references',
|
||||
'orca skills get orchestration --reference recovery-and-cleanup'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['skills', 'install'],
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const TERMINAL_SEND_COMMAND_SPEC: CommandSpec = {
|
||||
path: ['terminal', 'send'],
|
||||
summary: 'Send input to a live terminal',
|
||||
usage:
|
||||
'orca terminal send [--terminal <handle>] [--text <text>] [--enter] [--interrupt] [--wait-submit <seconds>] [--retry-request <id>] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'terminal',
|
||||
'text',
|
||||
'enter',
|
||||
'interrupt',
|
||||
'wait-submit',
|
||||
'retry-request'
|
||||
],
|
||||
notes: [
|
||||
'For a text-plus-Enter agent prompt, the result separates input acceptance from observed submission and turn start.',
|
||||
'--wait-submit only observes the accepted prompt for the requested duration; timeout returns the queued/input-accepted receipt and never resends.',
|
||||
'After an ambiguous transport failure, reissue the exact command with the reported --retry-request ID. The ID is bound to the prompt payload and exact terminal process incarnation.',
|
||||
'Older hosts accept the legacy raw input but report provider old-host and do not offer idempotent retry or submission observation.'
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Write one newline-terminated payload to stdout without doubling an existing newline. */
|
||||
export function writeStdoutLine(value: string): void {
|
||||
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatTerminalClose, formatTerminalFocus } from './terminal-format'
|
||||
import { formatTerminalClose, formatTerminalFocus, formatTerminalSend } from './terminal-format'
|
||||
|
||||
describe('formatTerminalFocus', () => {
|
||||
it('distinguishes superseded navigation from a winning focus', () => {
|
||||
@@ -59,3 +59,115 @@ describe('formatTerminalClose', () => {
|
||||
).toBe('Closed terminal term_live. The PTY is live.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTerminalSend', () => {
|
||||
it('exposes the provider and healthy delivery observation', () => {
|
||||
expect(
|
||||
formatTerminalSend({
|
||||
send: {
|
||||
handle: 'term_worker',
|
||||
accepted: true,
|
||||
bytesWritten: 8,
|
||||
prompt: {
|
||||
requestId: 'prompt-healthy',
|
||||
stages: ['input_accepted', 'turn_started'],
|
||||
provider: 'codex',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
[
|
||||
'Prompt prompt-healthy on term_worker: input_accepted -> turn_started.',
|
||||
'provider: codex',
|
||||
'delivery observation: supported'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
observation: 'permission' as const,
|
||||
warning: 'Resolve the permission prompt in the terminal',
|
||||
nextStep: '--retry-request prompt-unhealthy'
|
||||
},
|
||||
{
|
||||
observation: 'incarnation_replaced' as const,
|
||||
warning: 'the terminal process was replaced',
|
||||
nextStep: 'Inspect the current terminal before sending a new prompt'
|
||||
}
|
||||
])('warns and gives a next step for $observation', ({ observation, warning, nextStep }) => {
|
||||
const output = formatTerminalSend({
|
||||
send: {
|
||||
handle: 'term_worker',
|
||||
accepted: true,
|
||||
bytesWritten: 8,
|
||||
prompt: {
|
||||
requestId: 'prompt-unhealthy',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'codex',
|
||||
observation,
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain(`provider: codex`)
|
||||
expect(output).toContain(`delivery observation: ${observation}`)
|
||||
expect(output).toContain(`warning: delivery was not observed`)
|
||||
expect(output).toContain(warning)
|
||||
expect(output).toContain(nextStep)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ provider: 'claude' as const, expected: 'no turn start was observed' },
|
||||
{ provider: 'unsupported' as const, expected: 'this provider cannot report delivery' },
|
||||
{ provider: 'old-host' as const, expected: 'predates durable prompt receipts' }
|
||||
])('warns per provider when delivery was not observed ($provider)', ({ provider, expected }) => {
|
||||
const output = formatTerminalSend({
|
||||
send: {
|
||||
handle: 'term_worker',
|
||||
accepted: true,
|
||||
bytesWritten: 8,
|
||||
prompt: {
|
||||
requestId: 'prompt-unobserved',
|
||||
stages: ['input_accepted'],
|
||||
provider,
|
||||
observation: 'unsupported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain(expected)
|
||||
})
|
||||
|
||||
it('names the next command when a supported send never reached turn_started', () => {
|
||||
const output = formatTerminalSend({
|
||||
send: {
|
||||
handle: 'term_worker',
|
||||
accepted: true,
|
||||
bytesWritten: 8,
|
||||
prompt: {
|
||||
requestId: 'prompt-swallowed',
|
||||
stages: ['input_accepted'],
|
||||
provider: 'claude',
|
||||
observation: 'supported',
|
||||
processIncarnation: 'inc-1',
|
||||
generation: 1,
|
||||
baselineWorkingSequence: 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(output).toContain('no turn start was observed')
|
||||
expect(output).toContain('--retry-request prompt-swallowed --wait-submit <seconds>')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -178,7 +178,50 @@ export function formatTerminalSend(result: { send: RuntimeTerminalSend }): strin
|
||||
return copy
|
||||
}
|
||||
}
|
||||
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
|
||||
if (!result.send.accepted) {
|
||||
const reason = result.send.refusedReason ? `: ${result.send.refusedReason}` : ''
|
||||
return `Input refused by ${result.send.handle}${reason}.`
|
||||
}
|
||||
const prompt = result.send.prompt
|
||||
if (!prompt) {
|
||||
return `Sent ${result.send.bytesWritten} bytes to ${result.send.handle}.`
|
||||
}
|
||||
return [
|
||||
`Prompt ${prompt.requestId} on ${result.send.handle}: ${prompt.stages.join(' -> ')}.`,
|
||||
`provider: ${prompt.provider}`,
|
||||
`delivery observation: ${prompt.observation}`,
|
||||
...terminalSendWarnings(result.send).map((warning) => `warning: ${warning}`)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** The same warnings the text formatter prints, so a --json caller sees them too. */
|
||||
export function terminalSendWarnings(send: RuntimeTerminalSend): string[] {
|
||||
const warning = send.accepted && send.prompt ? promptObservationWarning(send.prompt) : null
|
||||
return warning ? [warning] : []
|
||||
}
|
||||
|
||||
function promptObservationWarning(
|
||||
prompt: NonNullable<RuntimeTerminalSend['prompt']>
|
||||
): string | null {
|
||||
if (prompt.observation === 'permission') {
|
||||
return `delivery was not observed because the provider requires permission. Resolve the permission prompt in the terminal, then reissue the exact command with --retry-request ${prompt.requestId} and --wait-submit <seconds>.`
|
||||
}
|
||||
if (prompt.observation === 'incarnation_replaced') {
|
||||
return 'delivery was not observed because the terminal process was replaced. Inspect the current terminal before sending a new prompt; do not retry with this request ID.'
|
||||
}
|
||||
// Ordered before the unsupported arm: an agent provider that never reached turn_started
|
||||
// needs the swallowed-Enter recovery even if this host could not observe the submit.
|
||||
if (prompt.provider !== 'unsupported' && prompt.provider !== 'old-host') {
|
||||
return prompt.stages.includes('turn_started')
|
||||
? null
|
||||
: `input was accepted but no turn start was observed, so the Enter may have been swallowed. Confirm delivery by reissuing the exact command with --retry-request ${prompt.requestId} --wait-submit <seconds>; the same request ID replays the receipt instead of sending the prompt again.`
|
||||
}
|
||||
if (prompt.observation === 'unsupported') {
|
||||
return prompt.provider === 'old-host'
|
||||
? 'this host predates durable prompt receipts. Update Orca on the execution host, and inspect the terminal before retrying an ambiguous send.'
|
||||
: 'input was accepted, but this provider cannot report delivery. Inspect the terminal before retrying.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatTerminalRename(result: { rename: RuntimeTerminalRename }): string {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Why: the runtime answers an unresolvable `--worktree` with a bare
|
||||
// `selector_not_found` — no offending value and no grammar — so a caller who passed
|
||||
// a repo id where a worktree id belongs cannot tell what was wrong (#16904). The CLI
|
||||
// is the only layer that still knows what the caller typed, so it shapes the recovery
|
||||
// here, in the same validFlags/suggestions/nextSteps shape as an unknown-flag error.
|
||||
|
||||
export const WORKTREE_SELECTOR_FORMS = [
|
||||
'id:<repo-id>::<absolute-path>',
|
||||
'path:<absolute-path>',
|
||||
'name:<display-name>',
|
||||
'branch:<branch>',
|
||||
'identity:<identity-key>',
|
||||
'issue:<number>',
|
||||
'current',
|
||||
'active'
|
||||
] as const
|
||||
|
||||
export type WorktreeSelectorRecovery = {
|
||||
selector: string
|
||||
validSelectorForms: readonly string[]
|
||||
suggestions: readonly string[]
|
||||
nextSteps: readonly string[]
|
||||
}
|
||||
|
||||
const PREFIXES = ['id:', 'path:', 'name:', 'branch:', 'identity:', 'issue:']
|
||||
|
||||
function suggestForms(selector: string): string[] {
|
||||
if (selector.startsWith('id:')) {
|
||||
// A worktree id is `<repo-id>::<path>`; the repo id alone names no checkout.
|
||||
return selector.includes('::')
|
||||
? []
|
||||
: [`id:${selector.slice(3)}::<absolute-path>`, 'path:<absolute-path>']
|
||||
}
|
||||
if (PREFIXES.some((prefix) => selector.startsWith(prefix))) {
|
||||
return []
|
||||
}
|
||||
return selector.startsWith('/') || /^[A-Za-z]:[\\/]/.test(selector)
|
||||
? [`path:${selector}`]
|
||||
: [`id:${selector}::<absolute-path>`, `name:${selector}`, `branch:${selector}`]
|
||||
}
|
||||
|
||||
export function worktreeSelectorRecovery(selector: string): WorktreeSelectorRecovery {
|
||||
const suggestions = suggestForms(selector)
|
||||
return {
|
||||
selector,
|
||||
validSelectorForms: WORKTREE_SELECTOR_FORMS,
|
||||
suggestions,
|
||||
nextSteps: [
|
||||
`No Orca workspace matched the worktree selector "${selector}".`,
|
||||
...(suggestions.length > 0 ? [`Did you mean: ${suggestions.join(', ')}`] : []),
|
||||
`Valid selector forms: ${WORKTREE_SELECTOR_FORMS.join(', ')}.`,
|
||||
'List the exact values with `orca worktree list --json`; a bare repository id is not a worktree id.'
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,22 @@ describe('the observation clock a relay replay must not restamp', () => {
|
||||
expect(replayed.evidenceObservedAt).toBe(T0)
|
||||
})
|
||||
|
||||
it('carries the observation time out of getStatusSnapshot, not just the listener', () => {
|
||||
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
|
||||
vi.setSystemTime(T0 + 25 * 60 * 1000)
|
||||
server.clearStatusEntriesForConnection(CONNECTION)
|
||||
ingest(
|
||||
server,
|
||||
{ hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' },
|
||||
{ isReplay: true }
|
||||
)
|
||||
|
||||
// The fleet projection reads this snapshot, not the listener payload.
|
||||
const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)!
|
||||
expect(row.receivedAt).toBeGreaterThan(T0 + 25 * 60 * 1000 - 1)
|
||||
expect(row.evidenceObservedAt).toBe(T0)
|
||||
})
|
||||
|
||||
it('lets a live event restamp the observation time after a replay', () => {
|
||||
ingest(server, { hook_event_name: 'UserPromptSubmit', prompt: 'do the thing' })
|
||||
vi.setSystemTime(T0 + 25 * 60 * 1000)
|
||||
|
||||
@@ -59,6 +59,9 @@ export function toAgentStatusIpcPayload(
|
||||
worktreeId: entry.worktreeId,
|
||||
connectionId: entry.connectionId,
|
||||
receivedAt: entry.receivedAt,
|
||||
...(entry.evidenceObservedAt !== undefined
|
||||
? { evidenceObservedAt: entry.evidenceObservedAt }
|
||||
: {}),
|
||||
stateStartedAt: entry.stateStartedAt,
|
||||
...(entry.providerSession ? { providerSession: entry.providerSession } : {}),
|
||||
...(entry.providerSessionOnly ? { providerSessionOnly: true } : {}),
|
||||
|
||||
@@ -704,7 +704,7 @@ describe('DaemonClient', () => {
|
||||
|
||||
await expect(
|
||||
client.notifyWithSettlement('write', { data: 'x'.repeat(NDJSON_MAX_LINE_BYTES) })
|
||||
).resolves.toBe(false)
|
||||
).resolves.toEqual({ outcome: 'refused', reason: 'encode_failed' })
|
||||
expect(writeSpy).not.toHaveBeenCalled()
|
||||
expect(client.isConnected()).toBe(true)
|
||||
})
|
||||
@@ -737,7 +737,11 @@ describe('DaemonClient', () => {
|
||||
|
||||
await expect(
|
||||
client.notifyWithSettlement('write', { sessionId: 'session-1', data: 'hello' })
|
||||
).resolves.toBe(false)
|
||||
).resolves.toEqual({
|
||||
outcome: 'unverifiable',
|
||||
reason: 'transport_settlement_lost',
|
||||
bytesHandedToTransport: true
|
||||
})
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -754,9 +758,14 @@ describe('DaemonClient', () => {
|
||||
{ sessionId: 'session-1', data: 'hello' },
|
||||
5000
|
||||
)
|
||||
const settled = expect(pending).resolves.toEqual({
|
||||
outcome: 'unverifiable',
|
||||
reason: 'settlement_timeout',
|
||||
bytesHandedToTransport: true
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
|
||||
await expect(pending).resolves.toBe(false)
|
||||
await settled
|
||||
expect(client.isConnected()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
PROTOCOL_VERSION,
|
||||
NOTIFY_PREFIX,
|
||||
DaemonConnectionLostError,
|
||||
DaemonProtocolError
|
||||
DaemonProtocolError,
|
||||
type DaemonEndpointIdentity
|
||||
} from './types'
|
||||
import type { DaemonEndpointIdentity } from './types'
|
||||
import { writeRefused, type WriteSettlement } from '../../shared/pty-write-settlement'
|
||||
import {
|
||||
armDaemonSocketCloseHandlers,
|
||||
connectDaemonSocket,
|
||||
@@ -253,18 +254,17 @@ export class DaemonClient {
|
||||
type: string,
|
||||
payload: unknown,
|
||||
timeoutMs = NOTIFY_SETTLEMENT_TIMEOUT_MS
|
||||
): Promise<boolean> {
|
||||
): Promise<WriteSettlement> {
|
||||
if (!this.connected || !this.controlSocket) {
|
||||
return false
|
||||
return writeRefused('endpoint_disconnected')
|
||||
}
|
||||
|
||||
const id = `${NOTIFY_PREFIX}${++this.requestCounter}`
|
||||
const msg = { id, type, ...(payload !== undefined ? { payload } : {}) }
|
||||
const socket = this.controlSocket
|
||||
const generation = this.connectionGeneration
|
||||
return await writeNotifyWithSettlement({
|
||||
socket,
|
||||
message: msg,
|
||||
message: { id, type, ...(payload !== undefined ? { payload } : {}) },
|
||||
timeoutMs,
|
||||
onUndeliverable: () => {
|
||||
if (this.controlSocket === socket && this.connectionGeneration === generation) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { writeNotifyWithSettlement } from './daemon-client-notify-settlement'
|
||||
|
||||
describe('daemon notify partial handoff', () => {
|
||||
it.each(['pointer', '\r'])(
|
||||
'retains possible handoff after writing %j then throwing',
|
||||
async (data) => {
|
||||
const transported: string[] = []
|
||||
const socket = {
|
||||
write: (encoded: string) => {
|
||||
transported.push(encoded.slice(0, -1))
|
||||
throw new Error('socket failed after partial flush')
|
||||
}
|
||||
} as unknown as Socket
|
||||
const onUndeliverable = vi.fn()
|
||||
|
||||
const settlement = await writeNotifyWithSettlement({
|
||||
socket,
|
||||
message: { id: 'notify-1', type: 'write', payload: { sessionId: 'pty-1', data } },
|
||||
timeoutMs: 100,
|
||||
onUndeliverable
|
||||
})
|
||||
|
||||
expect(transported).toHaveLength(1)
|
||||
expect(settlement).toEqual({
|
||||
outcome: 'unverifiable',
|
||||
reason: 'endpoint_write_threw',
|
||||
bytesHandedToTransport: true
|
||||
})
|
||||
expect(onUndeliverable).toHaveBeenCalledOnce()
|
||||
}
|
||||
)
|
||||
it('preserves the write verdict when disconnect notification throws', async () => {
|
||||
const socket = {
|
||||
write: () => {
|
||||
throw new Error('partial flush')
|
||||
}
|
||||
} as unknown as Socket
|
||||
await expect(
|
||||
writeNotifyWithSettlement({
|
||||
socket,
|
||||
message: { type: 'write', payload: { data: 'pointer' } },
|
||||
timeoutMs: 100,
|
||||
onUndeliverable: () => {
|
||||
throw new Error('renderer destroyed during disconnect')
|
||||
}
|
||||
})
|
||||
).resolves.toEqual({
|
||||
outcome: 'unverifiable',
|
||||
reason: 'endpoint_write_threw',
|
||||
bytesHandedToTransport: true
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { encodeNdjson } from './ndjson'
|
||||
import {
|
||||
WRITE_ACCEPTED,
|
||||
writeRefused,
|
||||
writeUnverifiable,
|
||||
type WriteSettlement
|
||||
} from '../../shared/pty-write-settlement'
|
||||
|
||||
export type NotifySettlementRequest = {
|
||||
socket: Socket
|
||||
@@ -9,35 +15,51 @@ export type NotifySettlementRequest = {
|
||||
onUndeliverable: () => void
|
||||
}
|
||||
|
||||
/** Ambiguity is returned, not thrown: a stalled socket cannot prove the bytes never left. */
|
||||
export async function writeNotifyWithSettlement(
|
||||
request: NotifySettlementRequest
|
||||
): Promise<boolean> {
|
||||
): Promise<WriteSettlement> {
|
||||
const { socket, message, timeoutMs, onUndeliverable } = request
|
||||
let encoded: string
|
||||
try {
|
||||
encoded = encodeNdjson(message)
|
||||
} catch {
|
||||
return false
|
||||
return writeRefused('encode_failed')
|
||||
}
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
return await new Promise<WriteSettlement>((resolve) => {
|
||||
let settled = false
|
||||
const settle = (accepted: boolean): void => {
|
||||
const settle = (settlement: WriteSettlement): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
resolve(accepted)
|
||||
resolve(settlement)
|
||||
}
|
||||
const rejectAndDisconnect = (): void => {
|
||||
onUndeliverable()
|
||||
settle(false)
|
||||
const disconnectAndSettle = (settlement: WriteSettlement): void => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settle(settlement)
|
||||
try {
|
||||
onUndeliverable()
|
||||
} catch (error) {
|
||||
console.warn('[daemon] Write recovery notification failed:', error)
|
||||
}
|
||||
}
|
||||
const timer = setTimeout(rejectAndDisconnect, timeoutMs)
|
||||
const timer = setTimeout(
|
||||
() => disconnectAndSettle(writeUnverifiable('settlement_timeout', true)),
|
||||
timeoutMs
|
||||
)
|
||||
try {
|
||||
socket.write(encoded, (error) => (error ? rejectAndDisconnect() : settle(true)))
|
||||
socket.write(encoded, (error) =>
|
||||
error
|
||||
? disconnectAndSettle(writeUnverifiable('transport_settlement_lost', true))
|
||||
: settle(WRITE_ACCEPTED)
|
||||
)
|
||||
} catch {
|
||||
rejectAndDisconnect()
|
||||
// A synchronous throw can follow a partial flush.
|
||||
disconnectAndSettle(writeUnverifiable('endpoint_write_threw', true))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,7 +61,12 @@ export abstract class DaemonPtyEventSubscriptions extends DaemonPtySessionInvent
|
||||
protected emitWriteUnavailable(id: string): void {
|
||||
// oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration
|
||||
for (const listener of [...this.writeUnavailableListeners]) {
|
||||
listener({ id })
|
||||
try {
|
||||
listener({ id })
|
||||
} catch (error) {
|
||||
// Renderer notification failure must not cancel recovery or erase write evidence.
|
||||
console.warn('[daemon] Write unavailable listener failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonPtyRouter } from './daemon-pty-router'
|
||||
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { settledWriteStub, stubWriteSettlement } from '../providers/settled-pty-write-stub'
|
||||
import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import {
|
||||
AGENT_SESSION_CLAIM_DAEMON_PROTOCOL_VERSION,
|
||||
@@ -76,7 +77,7 @@ function createAdapter(
|
||||
write: vi.fn((id: string, data: string) => {
|
||||
writes.push({ id, data })
|
||||
}),
|
||||
writeWithSettlement: vi.fn(async () => true),
|
||||
writeWithSettlement: vi.fn(settledWriteStub()),
|
||||
resize: vi.fn(),
|
||||
setPtyBackgrounded: vi.fn(),
|
||||
getBufferSnapshot: vi.fn(async () => null),
|
||||
@@ -465,11 +466,13 @@ describe('DaemonPtyRouter', () => {
|
||||
it('routes settlement-aware writes to the owning daemon generation', async () => {
|
||||
const current = createAdapter('current')
|
||||
const legacy = createAdapter('legacy', ['legacy-session'])
|
||||
vi.mocked(legacy.writeWithSettlement).mockResolvedValue(false)
|
||||
vi.mocked(legacy.writeWithSettlement).mockResolvedValue(stubWriteSettlement(false))
|
||||
const router = new DaemonPtyRouter({ current, legacy: [legacy] })
|
||||
await router.discoverLegacySessions()
|
||||
|
||||
await expect(router.writeWithSettlement('legacy-session', 'pointer')).resolves.toBe(false)
|
||||
await expect(router.writeWithSettlement('legacy-session', 'pointer')).resolves.toEqual(
|
||||
stubWriteSettlement(false)
|
||||
)
|
||||
expect(legacy.writeWithSettlement).toHaveBeenCalledWith('legacy-session', 'pointer')
|
||||
expect(current.writeWithSettlement).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { shouldHandoffDaemonHistory } from './daemon-history-handoff'
|
||||
import type { DaemonPtyRouterDataEvent, DaemonPtyRouterExitEvent } from './daemon-pty-router-events'
|
||||
import { DaemonSessionOwnerResolver } from './daemon-session-owner-resolution'
|
||||
import type { WriteSettlement } from '../../shared/pty-write-settlement'
|
||||
|
||||
export class DaemonPtyRouter implements IPtyProvider {
|
||||
private current: DaemonPtyAdapter
|
||||
@@ -93,7 +94,7 @@ export class DaemonPtyRouter implements IPtyProvider {
|
||||
return this.adapterFor(id).write(id, data)
|
||||
}
|
||||
|
||||
writeWithSettlement(id: string, data: string): Promise<boolean> {
|
||||
writeWithSettlement(id: string, data: string): Promise<WriteSettlement> {
|
||||
return this.adapterFor(id).writeWithSettlement(id, data)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,19 +3,18 @@ import { isUnknownRequestTypeError } from './daemon-endpoint-errors'
|
||||
import { GET_SIZE_PROTOCOL_VERSION } from './daemon-protocol-version'
|
||||
import { readDaemonAppliedPtySize, type DaemonAppliedPtySize } from './daemon-pty-applied-size'
|
||||
import { FinalCheckpointWaitExpiredError } from './daemon-pty-lifecycle-errors'
|
||||
import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn'
|
||||
import { DaemonPtySessionInput } from './daemon-pty-session-input'
|
||||
import { remainingDaemonRequestTimeoutMs } from './daemon-request-deadline'
|
||||
import type { ColdRestoreInfo } from './history-reader'
|
||||
import { normalizeWslColdRestoreCwd } from './wsl-cold-restore-cwd'
|
||||
import { SessionNotFoundError, type ListSessionsResult } from './types'
|
||||
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
import type { PtySpawnResult } from '../providers/types'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
export const LIVENESS_PROBE_TIMEOUT_MS = 2_000
|
||||
|
||||
const MAX_TOMBSTONES = 1000
|
||||
|
||||
export abstract class DaemonPtySessionControl extends DaemonPtySessionSpawn {
|
||||
export abstract class DaemonPtySessionControl extends DaemonPtySessionInput {
|
||||
async attach(id: string): Promise<Pick<PtySpawnResult, 'providerSequence'> | void> {
|
||||
await this.ensureConnected()
|
||||
if (!this.canDelegateBackgroundToDaemon) {
|
||||
@@ -86,90 +85,6 @@ export abstract class DaemonPtySessionControl extends DaemonPtySessionSpawn {
|
||||
}
|
||||
}
|
||||
|
||||
write(id: string, data: string): boolean {
|
||||
const recoverable = this.prepareWrite(id)
|
||||
return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable)
|
||||
}
|
||||
|
||||
async writeWithSettlement(id: string, data: string): Promise<boolean> {
|
||||
const recoverable = this.prepareWrite(id)
|
||||
return this.finishWrite(
|
||||
id,
|
||||
await this.client.notifyWithSettlement('write', { sessionId: id, data }),
|
||||
recoverable
|
||||
)
|
||||
}
|
||||
|
||||
protected prepareWrite(id: string): boolean {
|
||||
this.markSessionDirty(id)
|
||||
// Why recoverable and not just active: rejecting a write asks the pane to remount,
|
||||
// which only helps if this endpoint can come back. A legacy adapter has no respawn,
|
||||
// so its reattach fails and the pane rebuilds empty — losing scrollback the user
|
||||
// could still read. Keep the pre-existing silent drop for those.
|
||||
const recoverable =
|
||||
this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn)
|
||||
if (
|
||||
recoverable &&
|
||||
(this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected())
|
||||
) {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
this.reconnectAfterWriteFailure()
|
||||
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
|
||||
}
|
||||
return recoverable
|
||||
}
|
||||
|
||||
protected finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean {
|
||||
if (!delivered && recoverable) {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
this.reconnectAfterWriteFailure()
|
||||
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
this.markSessionDirty(id)
|
||||
this.client.notify('resize', { sessionId: id, cols, rows })
|
||||
}
|
||||
|
||||
pauseProducer(id: string): void {
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
this.pausedProducerSessionIds.add(id)
|
||||
this.client.notify('pausePty', { sessionId: id })
|
||||
}
|
||||
|
||||
resumeProducer(id: string): void {
|
||||
this.producerResumesOwedOnReconnect.delete(id)
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
this.pausedProducerSessionIds.delete(id)
|
||||
this.client.notify('resumePty', { sessionId: id })
|
||||
}
|
||||
|
||||
// Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning.
|
||||
setPtyBackgrounded(id: string, background: boolean): void {
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
// Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream.
|
||||
// Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan
|
||||
// authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never
|
||||
// retract it, so a TUI exiting while hidden would strand the subscription and the
|
||||
// next theme flip would inject CSI 997 into its replacement shell. Declining to
|
||||
// background keeps main's scanner — which emits both facts — authoritative.
|
||||
const safeBackground = this.canDelegateBackgroundToDaemon && background
|
||||
if (safeBackground) {
|
||||
this.backgroundedSessionIds.add(id)
|
||||
} else {
|
||||
this.backgroundedSessionIds.delete(id)
|
||||
}
|
||||
this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground })
|
||||
}
|
||||
|
||||
async shutdown(
|
||||
id: string,
|
||||
opts: { immediate?: boolean; keepHistory?: boolean; deadlineMs?: number }
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { DaemonPtySessionSpawn } from './daemon-pty-session-spawn'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { writeRefused, type WriteSettlement } from '../../shared/pty-write-settlement'
|
||||
|
||||
export abstract class DaemonPtySessionInput extends DaemonPtySessionSpawn {
|
||||
write(id: string, data: string): boolean {
|
||||
const recoverable = this.prepareWrite(id)
|
||||
return this.finishWrite(id, this.client.notify('write', { sessionId: id, data }), recoverable)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the settlement instead of throwing: the recovery side effects that
|
||||
* `finishWrite` performs still run, but an ambiguous notify must not reach the caller
|
||||
* as a rejection it would read as a proven refusal.
|
||||
*/
|
||||
async writeWithSettlement(id: string, data: string): Promise<WriteSettlement> {
|
||||
let recoverable: boolean
|
||||
try {
|
||||
recoverable = this.prepareWrite(id)
|
||||
} catch (error) {
|
||||
if (error instanceof PtyWriteUnavailableError) {
|
||||
// prepareWrite already armed recovery and wrote nothing, so this is proven refusal.
|
||||
return writeRefused('endpoint_awaiting_recovery')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const settlement = await this.client.notifyWithSettlement('write', { sessionId: id, data })
|
||||
if (settlement.outcome !== 'accepted' && recoverable) {
|
||||
this.armWriteRecovery(id)
|
||||
}
|
||||
return settlement
|
||||
}
|
||||
|
||||
protected prepareWrite(id: string): boolean {
|
||||
this.markSessionDirty(id)
|
||||
// Why recoverable and not just active: rejecting a write asks the pane to remount,
|
||||
// which only helps if this endpoint can come back. A legacy adapter has no respawn,
|
||||
// so its reattach fails and the pane rebuilds empty — losing scrollback the user
|
||||
// could still read. Keep the pre-existing silent drop for those.
|
||||
const recoverable =
|
||||
this.activeSessionIds.has(id) && !this.respawnAdoptionClosed && Boolean(this.respawnFn)
|
||||
if (
|
||||
recoverable &&
|
||||
(this.sessionsAwaitingDaemonRecovery.has(id) || !this.client.isConnected())
|
||||
) {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
this.reconnectAfterWriteFailure()
|
||||
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
|
||||
}
|
||||
return recoverable
|
||||
}
|
||||
|
||||
protected finishWrite(id: string, delivered: boolean, recoverable: boolean): boolean {
|
||||
if (!delivered && recoverable) {
|
||||
this.armWriteRecovery(id)
|
||||
throw new PtyWriteUnavailableError(`Daemon PTY "${id}" is awaiting recovery`)
|
||||
}
|
||||
return delivered
|
||||
}
|
||||
|
||||
protected armWriteRecovery(id: string): void {
|
||||
this.sessionsAwaitingDaemonRecovery.add(id)
|
||||
this.reconnectAfterWriteFailure()
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
this.markSessionDirty(id)
|
||||
this.client.notify('resize', { sessionId: id, cols, rows })
|
||||
}
|
||||
|
||||
pauseProducer(id: string): void {
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
this.pausedProducerSessionIds.add(id)
|
||||
this.client.notify('pausePty', { sessionId: id })
|
||||
}
|
||||
|
||||
resumeProducer(id: string): void {
|
||||
this.producerResumesOwedOnReconnect.delete(id)
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
this.pausedProducerSessionIds.delete(id)
|
||||
this.client.notify('resumePty', { sessionId: id })
|
||||
}
|
||||
|
||||
// Why fire-and-forget (like pausePty): just a delivery hint for the daemon's keep-tail stream thinning.
|
||||
setPtyBackgrounded(id: string, background: boolean): void {
|
||||
if (!this.supportsProducerFlowControl) {
|
||||
return
|
||||
}
|
||||
// Why: preserved daemons without a sequence-safe, faithful serializer cannot heal a thinned stream.
|
||||
// Why also gate on 2031 (#9993): backgrounding is what hands transient-fact scan
|
||||
// authority to the daemon. A pre-v29 daemon can announce a 2031 subscribe but never
|
||||
// retract it, so a TUI exiting while hidden would strand the subscription and the
|
||||
// next theme flip would inject CSI 997 into its replacement shell. Declining to
|
||||
// background keeps main's scanner — which emits both facts — authoritative.
|
||||
const safeBackground = this.canDelegateBackgroundToDaemon && background
|
||||
if (safeBackground) {
|
||||
this.backgroundedSessionIds.add(id)
|
||||
} else {
|
||||
this.backgroundedSessionIds.delete(id)
|
||||
}
|
||||
this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Socket } from 'node:net'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { writeNotifyWithSettlement } from './daemon-client-notify-settlement'
|
||||
import type { WriteSettlement } from '../../shared/pty-write-settlement'
|
||||
|
||||
it('preserves ambiguity when arming daemon recovery triggers a throwing listener', async () => {
|
||||
const adapter = new DaemonPtyAdapter({
|
||||
socketPath: '/unused/socket',
|
||||
tokenPath: '/unused/token',
|
||||
respawn: async () => {}
|
||||
})
|
||||
const state = adapter as unknown as {
|
||||
ensureConnected: () => Promise<void>
|
||||
activeSessionIds: Set<string>
|
||||
client: {
|
||||
isConnected: () => boolean
|
||||
notifyWithSettlement: (type: string, payload: unknown) => Promise<WriteSettlement>
|
||||
}
|
||||
}
|
||||
vi.spyOn(state, 'ensureConnected').mockResolvedValue()
|
||||
state.activeSessionIds.add('pty-1')
|
||||
vi.spyOn(state.client, 'isConnected').mockReturnValue(true)
|
||||
const transported: string[] = []
|
||||
const socket = {
|
||||
write: (encoded: string, callback: (error: Error) => void) => {
|
||||
transported.push(encoded)
|
||||
callback(new Error('connection lost after handoff'))
|
||||
}
|
||||
} as unknown as Socket
|
||||
vi.spyOn(state.client, 'notifyWithSettlement').mockImplementation((type, payload) =>
|
||||
writeNotifyWithSettlement({
|
||||
socket,
|
||||
message: { type, payload },
|
||||
timeoutMs: 100,
|
||||
onUndeliverable: () => {}
|
||||
})
|
||||
)
|
||||
adapter.onWriteUnavailable(() => {
|
||||
throw new Error('renderer send failed')
|
||||
})
|
||||
try {
|
||||
await expect(adapter.writeWithSettlement('pty-1', 'pointer')).resolves.toEqual({
|
||||
outcome: 'unverifiable',
|
||||
reason: 'transport_settlement_lost',
|
||||
bytesHandedToTransport: true
|
||||
})
|
||||
expect(transported).toHaveLength(1)
|
||||
expect(state.ensureConnected).toHaveBeenCalledOnce()
|
||||
} finally {
|
||||
adapter.dispose()
|
||||
}
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { DegradedDaemonPtyProvider } from './degraded-daemon-pty-provider'
|
||||
import { DEGRADED_DAEMON_RECOVERY_RETRY_MS } from './degraded-daemon-fresh-spawn-routing'
|
||||
import type { DaemonPtyAdapter } from './daemon-pty-adapter'
|
||||
import { settledWriteStub, stubWriteSettlement } from '../providers/settled-pty-write-stub'
|
||||
import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types'
|
||||
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
|
||||
import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from './daemon-errors'
|
||||
@@ -37,7 +38,7 @@ function createProvider(
|
||||
probePtyLiveness: vi.fn(async (id: string) => sessions.includes(id)),
|
||||
providesAgentSessionOwnerListings: vi.fn(() => authoritativeOwnerListings),
|
||||
write: vi.fn(),
|
||||
writeWithSettlement: vi.fn(async () => true),
|
||||
writeWithSettlement: vi.fn(settledWriteStub()),
|
||||
resize: vi.fn(),
|
||||
shutdown: vi.fn(async (id: string) => {
|
||||
const idx = sessions.indexOf(id)
|
||||
@@ -378,13 +379,17 @@ describe('DegradedDaemonPtyProvider', () => {
|
||||
it('preserves settlement through daemon and fallback routes', async () => {
|
||||
const current = createDaemonAdapter('daemon', ['daemon-session'])
|
||||
const fallback = createProvider('fallback')
|
||||
vi.mocked(current.writeWithSettlement).mockResolvedValue(false)
|
||||
vi.mocked(current.writeWithSettlement).mockResolvedValue(stubWriteSettlement(false))
|
||||
const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback })
|
||||
await provider.discoverDaemonSessions()
|
||||
const fresh = await provider.spawn({ cols: 80, rows: 24 })
|
||||
|
||||
await expect(provider.writeWithSettlement('daemon-session', 'old')).resolves.toBe(false)
|
||||
await expect(provider.writeWithSettlement(fresh.id, 'new')).resolves.toBe(true)
|
||||
await expect(provider.writeWithSettlement('daemon-session', 'old')).resolves.toEqual(
|
||||
stubWriteSettlement(false)
|
||||
)
|
||||
await expect(provider.writeWithSettlement(fresh.id, 'new')).resolves.toEqual(
|
||||
stubWriteSettlement(true)
|
||||
)
|
||||
expect(current.writeWithSettlement).toHaveBeenCalledWith('daemon-session', 'old')
|
||||
expect(fallback.writeWithSettlement).toHaveBeenCalledWith(fresh.id, 'new')
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './degraded-daemon-session-routing'
|
||||
import { DegradedDaemonFreshSpawnRouter } from './degraded-daemon-fresh-spawn-routing'
|
||||
import { DegradedDaemonOwnerRecovery } from './degraded-daemon-owner-recovery'
|
||||
import type { WriteSettlement } from '../../shared/pty-write-settlement'
|
||||
|
||||
export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
readonly isDegraded = true
|
||||
@@ -112,11 +113,8 @@ export class DegradedDaemonPtyProvider implements IPtyProvider {
|
||||
return this.providerFor(id).write(id, data)
|
||||
}
|
||||
|
||||
async writeWithSettlement(id: string, data: string): Promise<boolean> {
|
||||
const provider = this.providerFor(id)
|
||||
return provider.writeWithSettlement
|
||||
? await provider.writeWithSettlement(id, data)
|
||||
: provider.write(id, data) !== false
|
||||
async writeWithSettlement(id: string, data: string): Promise<WriteSettlement> {
|
||||
return await this.providerFor(id).writeWithSettlement(id, data)
|
||||
}
|
||||
|
||||
resize(id: string, cols: number, rows: number): void {
|
||||
|
||||
@@ -188,7 +188,8 @@ describe('agentStatus:getSnapshot IPC', () => {
|
||||
coordinatorHandle: 'term-parent'
|
||||
}
|
||||
: undefined
|
||||
)
|
||||
),
|
||||
getTerminalProcessIncarnation: vi.fn(() => 'pty-1:inc-1')
|
||||
}
|
||||
const { registerAgentHookHandlers } = await import('./agent-hooks')
|
||||
registerAgentHookHandlers(runtime)
|
||||
|
||||
@@ -1,14 +1,101 @@
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-ipc-payload'
|
||||
import {
|
||||
mintFleetAgentStatusEvidence,
|
||||
type FleetAgentStatusEvidence,
|
||||
type FleetEvidenceBinding
|
||||
} from '../../shared/orchestration-fleet-agent-status-evidence'
|
||||
import { isValidTerminalTabId } from '../../shared/terminal-tab-id'
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
|
||||
export type AgentStatusRuntimeEnrichment = Pick<
|
||||
OrcaRuntimeService,
|
||||
'getAgentStatusTerminalHandleForPaneKey' | 'getAgentStatusOrchestrationContextForPaneKey'
|
||||
| 'getAgentStatusTerminalHandleForPaneKey'
|
||||
| 'getAgentStatusOrchestrationContextForPaneKey'
|
||||
| 'getTerminalProcessIncarnation'
|
||||
>
|
||||
|
||||
const MAX_AGENT_STATUS_DROP_TAB_ID_LENGTH = 160
|
||||
|
||||
/** What the runtime resolved for a pane at the moment a status row was ingested. Captured by
|
||||
* `AgentStatusObservedPaneIdentities`, which is where the arms are documented. */
|
||||
export type ObservedAgentStatusPaneIdentity =
|
||||
| {
|
||||
kind: 'observed'
|
||||
terminalHandle: string
|
||||
processIncarnation: string
|
||||
/** The orchestration dispatch that owned the pane then, not whichever owns it now. */
|
||||
dispatchId: string | null
|
||||
}
|
||||
/** No status arrival was seen for this pane in this runtime: a hydrated replay row, or one
|
||||
* reconciled from it. Those carry `restoredUnconfirmed` and never project `live`. */
|
||||
| { kind: 'unobserved' }
|
||||
|
||||
/** The one place a pane key becomes terminal identity. Both the IPC payload the renderer
|
||||
* decodes and the fleet evidence the orchestration path reads are derived from this. */
|
||||
export function resolveAgentStatusBinding(
|
||||
paneKey: string,
|
||||
runtime: AgentStatusRuntimeEnrichment | undefined
|
||||
): FleetEvidenceBinding {
|
||||
const terminalHandle = runtime?.getAgentStatusTerminalHandleForPaneKey(paneKey)
|
||||
if (!terminalHandle) {
|
||||
return { kind: 'unresolved', reason: 'pane_not_bound' }
|
||||
}
|
||||
const processIncarnation = runtime?.getTerminalProcessIncarnation(terminalHandle)
|
||||
if (!processIncarnation) {
|
||||
return { kind: 'unresolved', reason: 'incarnation_unbound' }
|
||||
}
|
||||
const dispatchId = runtime?.getAgentStatusOrchestrationContextForPaneKey(paneKey)?.dispatchId
|
||||
return dispatchId
|
||||
? { kind: 'worker', dispatchId, terminalHandle, paneKey, processIncarnation }
|
||||
: { kind: 'pane', terminalHandle, paneKey, processIncarnation }
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity the row was observed under, fenced against the pane's identity now.
|
||||
*
|
||||
* The fleet path reads cached rows, so resolving identity here would describe whatever process
|
||||
* and dispatch the pane owns at read time rather than the one the agent reported from. A row
|
||||
* this runtime never observed keeps the current resolution: it is a hydrated replay, already
|
||||
* held off `live` by `restoredUnconfirmed`, and inventing an observation for it would be worse.
|
||||
*/
|
||||
export function resolveObservedAgentStatusBinding(
|
||||
paneKey: string,
|
||||
runtime: AgentStatusRuntimeEnrichment | undefined,
|
||||
observed: ObservedAgentStatusPaneIdentity
|
||||
): FleetEvidenceBinding {
|
||||
const current = resolveAgentStatusBinding(paneKey, runtime)
|
||||
if (observed.kind === 'unobserved' || current.kind === 'unresolved') {
|
||||
return current
|
||||
}
|
||||
if (
|
||||
current.terminalHandle !== observed.terminalHandle ||
|
||||
current.processIncarnation !== observed.processIncarnation
|
||||
) {
|
||||
return { kind: 'unresolved', reason: 'stale_incarnation' }
|
||||
}
|
||||
const terminal = {
|
||||
terminalHandle: observed.terminalHandle,
|
||||
paneKey,
|
||||
processIncarnation: observed.processIncarnation
|
||||
}
|
||||
return observed.dispatchId
|
||||
? { kind: 'worker', dispatchId: observed.dispatchId, ...terminal }
|
||||
: { kind: 'pane', ...terminal }
|
||||
}
|
||||
|
||||
export function mintAgentStatusFleetEvidence(
|
||||
data: AgentStatusIpcPayload,
|
||||
runtime: AgentStatusRuntimeEnrichment | undefined,
|
||||
observed: ObservedAgentStatusPaneIdentity
|
||||
): FleetAgentStatusEvidence {
|
||||
return mintFleetAgentStatusEvidence(
|
||||
data,
|
||||
resolveObservedAgentStatusBinding(data.paneKey, runtime, observed)
|
||||
)
|
||||
}
|
||||
|
||||
/** Unchanged wire shape: `agentStatus:set` and `agentStatus:getSnapshot` still publish the
|
||||
* same optional fields an older renderer decodes. Only the identity lookup is shared. */
|
||||
export function enrichAgentStatusIpcPayload(
|
||||
data: AgentStatusIpcPayload,
|
||||
runtime: AgentStatusRuntimeEnrichment | undefined
|
||||
|
||||
@@ -12,6 +12,15 @@ import {
|
||||
setLocalPtyProvider,
|
||||
unregisterSshPtyProvider
|
||||
} from './pty'
|
||||
import {
|
||||
writeRefused,
|
||||
writeUnverifiable,
|
||||
type WriteSettlement
|
||||
} from '../../shared/pty-write-settlement'
|
||||
|
||||
type SettledControllerDouble = {
|
||||
writeWithSettlement: (id: string, data: string) => WriteSettlement | Promise<WriteSettlement>
|
||||
}
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
@@ -94,6 +103,52 @@ describe('registerPtyHandlers', () => {
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
clearProviderPtyState(ptyId)
|
||||
})
|
||||
it('routes settled pointer writes through the installed SSH controller and preserves uncertainty', async () => {
|
||||
const connectionId = 'ssh-settled'
|
||||
const ptyId = `ssh:${connectionId}@@remote-pty`
|
||||
const provider = {
|
||||
...createAgentClaimProvider({}),
|
||||
writeWithSettlement: vi
|
||||
.fn()
|
||||
.mockResolvedValue(writeUnverifiable('transport_settlement_lost', true))
|
||||
}
|
||||
registerSshPtyProvider(connectionId, provider as never)
|
||||
setPtyOwnership(ptyId, connectionId)
|
||||
const controller = registerAgentClaimController() as unknown as SettledControllerDouble
|
||||
try {
|
||||
expect(controller.writeWithSettlement).toBeTypeOf('function')
|
||||
await expect(controller.writeWithSettlement(ptyId, 'pointer')).resolves.toEqual(
|
||||
writeUnverifiable('transport_settlement_lost', true)
|
||||
)
|
||||
expect(provider.writeWithSettlement).toHaveBeenCalledWith(ptyId, 'pointer')
|
||||
expect(provider.write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
clearProviderPtyState(ptyId)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a settled write before any bytes when the routed provider cannot settle', async () => {
|
||||
const connectionId = 'ssh-unsettled'
|
||||
const ptyId = `ssh:${connectionId}@@remote-pty`
|
||||
const provider = createAgentClaimProvider({}) as Record<string, unknown>
|
||||
// A provider predating the settled contract, reached through the production registry.
|
||||
delete provider.writeWithSettlement
|
||||
registerSshPtyProvider(connectionId, provider as never)
|
||||
setPtyOwnership(ptyId, connectionId)
|
||||
const controller = registerAgentClaimController() as unknown as SettledControllerDouble
|
||||
try {
|
||||
// Synchronous by construction: the refusal happens before any effect is attempted.
|
||||
expect(await controller.writeWithSettlement(ptyId, 'pointer')).toEqual(
|
||||
writeRefused('provider_cannot_settle')
|
||||
)
|
||||
expect(provider.write).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
clearProviderPtyState(ptyId)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves a provider write refusal for callers that gate follow-up input', () => {
|
||||
const provider = createAgentClaimProvider({})
|
||||
provider.write.mockReturnValue(false)
|
||||
|
||||
@@ -46,6 +46,8 @@ export function installPtyRuntimeController(deps: PtyRuntimeControllerDeps): voi
|
||||
adoptStablePane,
|
||||
spawn: async (args) => spawnPtyFromRuntimeController(deps, args),
|
||||
write: (ptyId, data) => writePtyFromRuntimeController(deps, ptyId, data),
|
||||
writeWithSettlement: (ptyId, data) =>
|
||||
writePtyFromRuntimeController(deps, ptyId, data, { waitForSettlement: true }),
|
||||
writeAgentSessionProof: (ptyId, data, authority) =>
|
||||
writePtyAgentSessionProofFromRuntimeController(ptyId, data, authority),
|
||||
probePtyLiveness: (ptyId) => probePtyLivenessFromRuntimeController(deps, ptyId),
|
||||
|
||||
@@ -9,21 +9,57 @@ import { inspectPtyProviderProcess } from '../../../providers/pty-process-inspec
|
||||
import type { PtyRuntimeControllerDeps } from './controller-deps'
|
||||
import { agentSessionPtyWriteGate } from '../../../runtime/agent-session-pty-write-gate'
|
||||
import { reportAgentSessionWriteRefusal } from '../agent-session-write-refusal-report'
|
||||
import {
|
||||
writeRefused,
|
||||
writeUnverifiable,
|
||||
type WriteSettlement
|
||||
} from '../../../../shared/pty-write-settlement'
|
||||
|
||||
export function writePtyFromRuntimeController(
|
||||
deps: PtyRuntimeControllerDeps,
|
||||
ptyId: string,
|
||||
data: string
|
||||
): boolean {
|
||||
): boolean
|
||||
export function writePtyFromRuntimeController(
|
||||
deps: PtyRuntimeControllerDeps,
|
||||
ptyId: string,
|
||||
data: string,
|
||||
options: { waitForSettlement: true }
|
||||
): WriteSettlement | Promise<WriteSettlement>
|
||||
export function writePtyFromRuntimeController(
|
||||
deps: PtyRuntimeControllerDeps,
|
||||
ptyId: string,
|
||||
data: string,
|
||||
options?: { waitForSettlement: true }
|
||||
): boolean | WriteSettlement | Promise<WriteSettlement> {
|
||||
// Why: the backstop for every runtime write path — query replies, followups, deliveries —
|
||||
// so a caller that forgets the typed gate still cannot reach a provider.
|
||||
const admission = agentSessionPtyWriteGate.admit(ptyId)
|
||||
if (!admission.admitted) {
|
||||
reportAgentSessionWriteRefusal(deps.mainWindow, ptyId, admission.refusal)
|
||||
return false
|
||||
return options?.waitForSettlement ? writeRefused('write_gate_denied') : false
|
||||
}
|
||||
let provider: IPtyProvider
|
||||
try {
|
||||
provider = getProviderForPty(ptyId)
|
||||
} catch {
|
||||
return options?.waitForSettlement ? writeRefused('provider_unavailable') : false
|
||||
}
|
||||
if (options?.waitForSettlement) {
|
||||
// A provider that cannot settle says so before any effect; synthesizing acceptance
|
||||
// from the fire-and-forget write is what cleared durable mailbox reservations.
|
||||
if (!provider.writeWithSettlement) {
|
||||
return writeRefused('provider_cannot_settle')
|
||||
}
|
||||
try {
|
||||
return provider.writeWithSettlement(ptyId, data)
|
||||
} catch {
|
||||
// A synchronous throw cannot prove the transport took nothing.
|
||||
return writeUnverifiable('provider_threw_after_handoff', true)
|
||||
}
|
||||
}
|
||||
try {
|
||||
return getProviderForPty(ptyId).write(ptyId, data) !== false
|
||||
return provider.write(ptyId, data) !== false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user