mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 16:02:37 +00:00
Merge branch 'fix-ergonomics' (early part) into integrate-fixes
This commit is contained in:
@@ -69,7 +69,7 @@ describe('orchestration kernel', () => {
|
||||
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(kernel).toContain('Never substitute a\nnon-Orca subagent tool')
|
||||
expect(squash(kernel)).toContain('Never substitute a non-Orca subagent tool')
|
||||
})
|
||||
|
||||
it('makes Dispatch identity, remote uncertainty, folders, and mixed versions a safety floor', () => {
|
||||
@@ -78,10 +78,10 @@ describe('orchestration kernel', () => {
|
||||
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(kernel).toContain('`live` / `unverifiable` / `exited`')
|
||||
expect(squash(kernel)).toContain('`live` / `unverifiable` / `exited`')
|
||||
expect(kernel).toContain('contact loss is not process death')
|
||||
expect(kernel).toContain('Folder workspaces are valid')
|
||||
expect(kernel).toContain('Treat unknown optional fields\n as absent')
|
||||
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')
|
||||
})
|
||||
@@ -104,37 +104,75 @@ describe('orchestration kernel', () => {
|
||||
|
||||
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 --task <task_a>')
|
||||
const secondStart = kernel.indexOf('worker-start --task <task_b>')
|
||||
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(kernel).toContain('start the full independent wave before waiting')
|
||||
expect(squash(kernel)).toContain('start the full independent wave before waiting')
|
||||
expect(kernel).toContain('`worker-start` is the normal path')
|
||||
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 --json`')
|
||||
expect(kernel).toContain('`requiresAction`, and literal `nextAction` argv')
|
||||
expect(kernel).toContain('`attention`')
|
||||
expect(kernel).toContain('reports `agentWait` null')
|
||||
expect(kernel).toContain('choose `worker-stop` or `worker-abandon`')
|
||||
})
|
||||
|
||||
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(kernel).toContain('oldest FIFO Delivery and replays that\nbatch until acknowledged')
|
||||
expect(kernel).toContain('Process every message')
|
||||
expect(kernel).toContain("decide each\nsettled terminal's next owner before acknowledging")
|
||||
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(kernel).toContain('worker-release --dispatch <dispatch_id>')
|
||||
expect(kernel).toContain('check --ack <delivery_id> --wait')
|
||||
expect(kernel).toContain('Do not follow\nit with `task-update --status completed`')
|
||||
expect(squash(kernel)).toContain('`worker-list --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()
|
||||
|
||||
expect(kernel).toContain('A timeout or empty result is\na checkpoint, not a failure')
|
||||
expect(kernel).toContain('Do not stop, retry, release, or launch a duplicate\neditor')
|
||||
expect(kernel).toContain('Never release because of\nidle state, timeout, heartbeat')
|
||||
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('Never release because of idle state, timeout, heartbeat')
|
||||
expect(kernel).toContain('never substitute `terminal close`')
|
||||
})
|
||||
|
||||
@@ -152,7 +190,7 @@ describe('orchestration kernel', () => {
|
||||
}
|
||||
expect(kernel).toContain('successful `orchestration send` proves durable enqueue')
|
||||
expect(kernel).toContain('best-effort attention only')
|
||||
expect(kernel).toContain('does not prove the recipient read the message')
|
||||
expect(squash(kernel)).toContain('does not prove the recipient read or accepted it')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -164,13 +202,18 @@ describe('owned orchestration references', () => {
|
||||
.filter((name) => name.endsWith('.md'))
|
||||
.sort()
|
||||
|
||||
expect([...new Set(routed)].sort()).toEqual(shipped)
|
||||
expect(routed).toHaveLength(shipped.length)
|
||||
expect(kernel).toContain('ORCA skills get orchestration --full')
|
||||
expect(kernel).toContain(
|
||||
'returns this exact kernel and every reference from\nthe same CLI build'
|
||||
const tableRoutes = [...kernel.matchAll(/^\|.*`references\/([^`]+\.md)`.*\|$/gmu)].map(
|
||||
(match) => match[1]
|
||||
)
|
||||
expect(kernel).toContain('If an older CLI rejects\n`--full`')
|
||||
|
||||
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')
|
||||
expect(squash(kernel)).toContain(
|
||||
'returns this exact kernel and every reference from the same CLI build'
|
||||
)
|
||||
expect(squash(kernel)).toContain('If an older CLI rejects `--full`')
|
||||
})
|
||||
|
||||
it('owns expanded waves, launch preferences, reuse, and review boundaries', () => {
|
||||
@@ -182,7 +225,9 @@ describe('owned orchestration references', () => {
|
||||
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(reference).toContain('post-review fixes and\nPR preparation remain with that owner')
|
||||
expect(squash(reference)).toContain(
|
||||
'post-review fixes and PR preparation remain with that owner'
|
||||
)
|
||||
})
|
||||
|
||||
it('owns worker heartbeat, ask resume, escalation, failure, and idle', () => {
|
||||
@@ -197,6 +242,13 @@ describe('owned orchestration references', () => {
|
||||
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`'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps heartbeat and worker_done recipes bound to the injected capability', () => {
|
||||
@@ -212,7 +264,7 @@ describe('owned orchestration references', () => {
|
||||
}
|
||||
expect(workerDone).not.toContain('--files-modified')
|
||||
expect(workerDone).not.toContain('--report-path')
|
||||
expect(reference).toContain('only when applicable, using actual\npaths')
|
||||
expect(squash(reference)).toContain('only when applicable, using actual paths')
|
||||
expect(reference).toContain('Do not send documentation placeholders as metadata')
|
||||
})
|
||||
|
||||
@@ -224,18 +276,24 @@ describe('owned orchestration references', () => {
|
||||
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(reference).toContain("`--on` selects\nonly the worker's execution server")
|
||||
expect(reference).toContain('route every follow-up, read,\nstop, and cleanup by Dispatch ID')
|
||||
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(reference).toContain('unknown stream\nopcodes can be silently dropped')
|
||||
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')
|
||||
})
|
||||
|
||||
it('owns FIFO mail, Dispatch addresses, groups, questions, and gates', () => {
|
||||
const reference = readReference('messaging-and-gates.md')
|
||||
|
||||
expect(reference).toContain('oldest FIFO Delivery')
|
||||
expect(reference).toContain('Process\nevery row')
|
||||
expect(squash(reference)).toContain('Process every row')
|
||||
expect(reference).toContain('send --to dispatch:<dispatch_id>')
|
||||
for (const group of ['@all', '@grok', '@cursor', '@worktree:<id>']) {
|
||||
expect(reference).toContain(group)
|
||||
@@ -244,7 +302,10 @@ describe('owned orchestration references', () => {
|
||||
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(reference).toContain('Wake and nudge are best-effort\nattention only')
|
||||
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('owns positive-evidence retry, unknown outcomes, retain/release, and no terminal close', () => {
|
||||
@@ -254,12 +315,33 @@ describe('owned orchestration references', () => {
|
||||
expect(squash(reference)).toContain('| `outcome_unknown` | Inspect')
|
||||
expect(squash(reference)).toContain('| Remote contact lost | Preserve `unverifiable`')
|
||||
expect(reference).toContain('--retry-of <dispatch_id>')
|
||||
expect(reference).toContain('Placement is never\nsilently inherited')
|
||||
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(reference).toContain('`release_pending`\nor `release_unknown`')
|
||||
expect(reference).toContain('Never substitute\n`terminal close`')
|
||||
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 --json')
|
||||
expect(reference).toContain("`worker-show`'s `observation.status` is PTY liveness only")
|
||||
expect(reference).toContain('`attention` categories, `requiresAction`')
|
||||
expect(reference).toContain('`nextAction` argv')
|
||||
expect(reference).toContain('the fleet verdict decides')
|
||||
})
|
||||
|
||||
it('owns the custom topology exception without claiming process ownership', () => {
|
||||
@@ -269,9 +351,9 @@ describe('owned orchestration references', () => {
|
||||
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(reference).toContain('creates no supervised worker\nresource row')
|
||||
expect(squash(reference)).toContain('creates no supervised worker resource row')
|
||||
expect(reference).toContain('Use `worker-start --terminal <handle>`')
|
||||
expect(reference).toContain('never\nuse it for an ownership handoff')
|
||||
expect(squash(reference)).toContain('never use it for an ownership handoff')
|
||||
})
|
||||
|
||||
it('owns legacy labels, read-only degradation, exact recovery, and takeover', () => {
|
||||
@@ -280,11 +362,11 @@ describe('owned orchestration references', () => {
|
||||
expect(reference).toContain('[LEGACY COMPATIBILITY]')
|
||||
expect(reference).toContain('[LEGACY RECOVERY REPLAY — MAY HAVE BEEN SEEN]')
|
||||
expect(reference).toContain('[LEGACY READ-ONLY]')
|
||||
expect(reference).toContain(
|
||||
'degrade to\nread-only inspection and never fall back to local execution'
|
||||
expect(squash(reference)).toContain(
|
||||
'degrade to read-only inspection and never fall back to local execution'
|
||||
)
|
||||
expect(reference).toContain(
|
||||
'must not spawn, write, signal, stop, switch, focus, split, or\ninject'
|
||||
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')
|
||||
|
||||
@@ -21,11 +21,9 @@ which attempt is authoritative, and when supervised work has settled.
|
||||
## Outcome
|
||||
|
||||
**Result:** every in-scope Task has one explicit outcome and every settled worker
|
||||
terminal has a next owner or cleanup decision.
|
||||
|
||||
**Done:** all expected Dispatches have settled, every delivered message was
|
||||
processed before acknowledgment, and each settled worker was reused, explicitly
|
||||
retained, or released.
|
||||
terminal has a next owner or cleanup decision. **Done:** all expected Dispatches
|
||||
have settled, every delivered message was processed before acknowledgment, and
|
||||
each settled worker was reused, explicitly retained, or released.
|
||||
|
||||
**Safe failure:** preserve work and authority and report the state as unknown or
|
||||
`unverifiable`. A timeout, quiet terminal, missing client, or lost remote
|
||||
@@ -56,12 +54,13 @@ claiming a worker was orchestrated, verify its Task and Dispatch exist.
|
||||
- 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. Do not require Git or assume every workspace is a
|
||||
worktree.
|
||||
- When a command requires an exact worktree selector, use the full
|
||||
`<repo-id>::<path>` value returned by Orca; a bare repo id is not a worktree id.
|
||||
- For a newly created workspace, pass that returned value as `id:<newFullWorktreeId>`;
|
||||
do not shorten it to the repository id.
|
||||
- Worktree selectors need the full `<repo-id>::<path>` value Orca returned,
|
||||
passed as `id:<fullWorktreeId>`; a bare repo id is not a worktree id.
|
||||
- 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
|
||||
@@ -70,11 +69,9 @@ claiming a worker was orchestrated, verify its Task and Dispatch exist.
|
||||
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.
|
||||
- Legacy takeover binds the authenticated invoking terminal; `--from` cannot
|
||||
nominate another coordinator. It preserves live work and fences the former
|
||||
coordinator, so never take over while that coordinator is still active.
|
||||
- A successful `orchestration send` proves durable enqueue. Its wake or nudge is
|
||||
best-effort attention only; it does not prove the recipient read the message,
|
||||
started a turn, or accepted steering.
|
||||
nominate another coordinator. Never take over while that coordinator is active.
|
||||
- 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
|
||||
|
||||
@@ -85,10 +82,13 @@ The injected preamble is authoritative. A dispatched worker must:
|
||||
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. Send `worker_done` exactly once, from the dispatched terminal, with a
|
||||
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.
|
||||
4. Append `--files-modified` and `--report-path` only with real values when
|
||||
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.
|
||||
|
||||
@@ -105,27 +105,27 @@ coordinator-supervised follow-up arrives with a fresh preamble and Task block.
|
||||
|
||||
## Canonical supervised loop
|
||||
|
||||
Confirm the runtime, create or bind one Run, create all independent Tasks, and
|
||||
start the full independent wave before waiting:
|
||||
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 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
|
||||
ORCA orchestration worker-start --spec "<worker A task>" --worktree current --agent codex --json
|
||||
ORCA orchestration worker-start --spec "<worker B task>" --worktree current --agent claude --json
|
||||
ORCA orchestration check --wait --types "worker_done,escalation,question" --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
Use Task dependencies only for real ordering. Prefer parallel waves over chains
|
||||
deeper than three or four steps. Nested workers obey the configured depth limit;
|
||||
creating another Run does not reset the caller's depth.
|
||||
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.
|
||||
|
||||
A consuming `check` returns the bound Run's oldest FIFO Delivery and replays that
|
||||
batch until acknowledged. Process every message. Reply to questions, validate
|
||||
that each `worker_done` belongs to the expected active Dispatch, and decide each
|
||||
settled terminal's next owner before acknowledging:
|
||||
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:
|
||||
|
||||
```text
|
||||
ORCA orchestration reply --id <message_id> --body "<answer>" --json
|
||||
@@ -137,10 +137,16 @@ 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 from timeout, idle state, heartbeat, relay loss, or missing client alone.
|
||||
|
||||
`worker-start` is the normal path. It composes placement, terminal readiness,
|
||||
prompt injection, and supervised resource ownership. Low-level
|
||||
`dispatch --inject` is reserved for an expressiveness gap and leaves an
|
||||
operator-created process unsupervised.
|
||||
After three consecutive empty waits, stop waiting blindly and enumerate with
|
||||
`ORCA orchestration worker-list --json`. Act on each row's `attention`,
|
||||
`requiresAction`, and literal `nextAction` argv. A worker that is not `live`,
|
||||
whose `worker-show` reports `agentWait` null, and whose `worker-read` shows no
|
||||
new progress is stalled, not working: load `references/recovery-and-cleanup.md`
|
||||
and choose `worker-stop` or `worker-abandon` explicitly.
|
||||
|
||||
`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
|
||||
|
||||
@@ -162,22 +168,22 @@ After an accepted success or failure report, immediately do exactly one:
|
||||
|
||||
Release is post-settlement cleanup, not cancellation. Never release because of
|
||||
idle state, timeout, heartbeat, status, question, escalation, or a rejected or
|
||||
stale completion. If release is uncertain, follow its exact recovery receipt;
|
||||
never substitute `terminal close`. Released output remains readable through
|
||||
stale completion. If release is uncertain, follow its exact recovery receipt and
|
||||
never substitute `terminal close`. Released output stays readable via
|
||||
`worker-read`.
|
||||
|
||||
A valid `worker_done` settles the Task and Dispatch automatically. Do not follow
|
||||
it with `task-update --status completed`. Do not end the coordinator turn until
|
||||
all expected Dispatches and settled terminals are accounted for.
|
||||
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 --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 --full` once and read only the named
|
||||
bundled reference. `--full` returns this exact kernel and every reference from
|
||||
the same CLI build in one deterministic document. If an older CLI rejects
|
||||
`--full`, keep this kernel's safety floor and use that command's `--help`; do not
|
||||
guess newer flags.
|
||||
bundled reference: it returns this exact kernel and every reference from the same
|
||||
CLI build. 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 |
|
||||
| ----------------------------------------------------------------------------------- | ----------------------------------------- |
|
||||
|
||||
@@ -10,11 +10,16 @@ accepted steering.
|
||||
## Coordinator delivery loop
|
||||
|
||||
```text
|
||||
ORCA orchestration check --wait --types "worker_done,escalation,question" --timeout-ms 900000 --json
|
||||
ORCA orchestration check --terminal <handle> --wait --types "worker_done,escalation,question" --timeout-ms 900000 --json
|
||||
ORCA orchestration reply --id <message_id> --body "<answer>" --json
|
||||
ORCA orchestration check --ack <delivery_id> --wait --types "worker_done,escalation,question" --timeout-ms 900000 --json
|
||||
```
|
||||
|
||||
`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
|
||||
@@ -35,7 +40,8 @@ ORCA orchestration send --to dispatch:<dispatch_id> --subject "Follow-up" --body
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
@@ -25,6 +25,16 @@ 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. `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,
|
||||
|
||||
@@ -3,21 +3,31 @@
|
||||
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 |
|
||||
| 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 |
|
||||
| Live PTY, stalled agent | Enumerate with `worker-list`; follow its `nextAction` |
|
||||
|
||||
## Inspect before acting
|
||||
|
||||
```text
|
||||
ORCA orchestration worker-list --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`, `attention` categories, `requiresAction`,
|
||||
and a literal `nextAction` argv to run. `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. A worker
|
||||
that is not `live`, reports `agentWait` null, and shows no new `worker-read`
|
||||
progress is stalled: stop waiting and choose `worker-stop` or `worker-abandon`.
|
||||
|
||||
`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
|
||||
@@ -27,6 +37,28 @@ 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.
|
||||
|
||||
## Retry, stop, and abandon
|
||||
|
||||
Retry only a positively proven failed or stopped attempt. Placement is never
|
||||
|
||||
@@ -30,6 +30,21 @@ ORCA orchestration ask --from <worker_handle> --dispatch-capability <capability>
|
||||
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`.
|
||||
|
||||
## Escalation
|
||||
|
||||
Escalate only before completion and only when the coordinator must intervene:
|
||||
|
||||
@@ -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
@@ -105,10 +105,20 @@ 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[] {
|
||||
return rankByDistance(
|
||||
const synonym = FLAG_SYNONYMS[flag]
|
||||
const ranked = rankByDistance(
|
||||
validFlags.map((candidate) => ({ label: candidate, distance: levenshtein(flag, candidate) }))
|
||||
)
|
||||
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.
|
||||
|
||||
@@ -1,8 +1,55 @@
|
||||
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('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', {
|
||||
|
||||
+35
-1
@@ -5,6 +5,7 @@ import {
|
||||
stripAutomationOwnerConflictCode
|
||||
} from '../shared/automation-owner-conflict'
|
||||
import { automationOwnerConflictRecovery } from './automation-owner-conflict-recovery'
|
||||
import { worktreeSelectorRecovery } from './worktree-selector-recovery'
|
||||
import { prepareComputerCliJsonResult } from './computer-format'
|
||||
import type { RuntimeRpcFailure, RuntimeRpcSuccess } from './runtime-client'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime/types'
|
||||
@@ -69,6 +70,21 @@ 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 errorCode(error: unknown): string | undefined {
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
return error.response.error.code
|
||||
}
|
||||
return error instanceof RuntimeClientError ? error.code : undefined
|
||||
}
|
||||
|
||||
export function printResult<TResult>(
|
||||
@@ -85,6 +101,10 @@ export function printResult<TResult>(
|
||||
|
||||
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, selector.nextSteps)
|
||||
}
|
||||
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
|
||||
if (hasOrchestrationRequestId(error.data)) {
|
||||
return message
|
||||
@@ -130,9 +150,19 @@ 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: response.error.data ?? selector } }
|
||||
: response,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const response: RuntimeRpcFailure = {
|
||||
id: 'local',
|
||||
@@ -201,6 +231,10 @@ function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
|
||||
if (error instanceof RuntimeClientError && error.data !== undefined) {
|
||||
return error.data
|
||||
}
|
||||
const selector = selectorRecovery(errorCode(error), context)
|
||||
if (selector) {
|
||||
return selector
|
||||
}
|
||||
const conflict = automationOwnerConflictRecovery(matchAutomationOwnerConflict(error))
|
||||
if (conflict) {
|
||||
return conflict
|
||||
|
||||
@@ -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; task_id: 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?.task_id ?? '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')
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -56,8 +56,11 @@ Slack, GitHub comments, or any other channel to reach a human during the run.
|
||||
# coordinator to do something before you can continue):
|
||||
orca orchestration send --from term_WORKER --type escalation --subject "Blocked: <reason>" --body "<details>" --task-id task_SNAP --dispatch-id ctx_SNAP
|
||||
|
||||
# Check for messages from the coordinator:
|
||||
orca orchestration check --terminal term_WORKER
|
||||
# Read coordinator follow-ups. Nothing interrupts you: a durable message only
|
||||
# arrives when you look, so run this at each natural checkpoint — before you
|
||||
# start a new file and after a test run — and once more immediately before
|
||||
# you send worker_done, so a redirect lands before the task settles.
|
||||
orca orchestration check --terminal term_WORKER --json
|
||||
|
||||
=== AFTER YOU SEND worker_done ===
|
||||
|
||||
|
||||
@@ -129,7 +129,18 @@ describe('buildDispatchPreamble', () => {
|
||||
expect(result).toMatch(/orchestration ask --from term_worker/)
|
||||
expect(result).toMatch(/orchestration send --from term_worker --type escalation/)
|
||||
expect(result).toContain('--task-id task_abc123 --dispatch-id ctx_def456')
|
||||
expect(result).toContain('orchestration check --terminal term_worker')
|
||||
expect(result).toContain('orchestration check --terminal term_worker --json')
|
||||
})
|
||||
|
||||
it('gives the worker a concrete cadence for reading coordinator follow-ups', () => {
|
||||
const result = buildDispatchPreamble(baseParams())
|
||||
const checkLine = result.indexOf('orchestration check --terminal term_worker --json')
|
||||
const cadence = result.slice(0, checkLine)
|
||||
|
||||
// Why: the transport is durable but never interrupts, so "you may check" produced
|
||||
// workers that never read a single follow-up.
|
||||
expect(cadence).toContain('before you\n # start a new file and after a test run')
|
||||
expect(cadence).toContain('immediately before\n # you send worker_done')
|
||||
})
|
||||
|
||||
it('carries the minted Dispatch capability on lifecycle and question commands', () => {
|
||||
|
||||
@@ -115,8 +115,11 @@ Slack, GitHub comments, or any other channel to reach a human during the run.
|
||||
# coordinator to do something before you can continue):
|
||||
${cli} orchestration send --from ${params.workerHandle}${capabilityFlag} --type escalation --subject "Blocked: <reason>" --body "<details>" --task-id ${params.taskId} --dispatch-id ${params.dispatchId}
|
||||
|
||||
# Check for messages from the coordinator:
|
||||
${cli} orchestration check --terminal ${params.workerHandle}
|
||||
# Read coordinator follow-ups. Nothing interrupts you: a durable message only
|
||||
# arrives when you look, so run this at each natural checkpoint — before you
|
||||
# start a new file and after a test run — and once more immediately before
|
||||
# you send worker_done, so a redirect lands before the task settles.
|
||||
${cli} orchestration check --terminal ${params.workerHandle} --json
|
||||
|
||||
${postDoneInstructions}`
|
||||
|
||||
|
||||
@@ -187,7 +187,7 @@ describe('orchestration federated setup evidence', () => {
|
||||
worker: {
|
||||
state: 'ready',
|
||||
stage: 'input_accepted',
|
||||
setup_state: 'failed',
|
||||
setupState: 'failed',
|
||||
effects: expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'setup', state: 'failed' }),
|
||||
expect.objectContaining({ kind: 'dispatch_input', state: 'accepted' })
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('manual Dispatch observation', () => {
|
||||
workerState: string
|
||||
terminalState: string | null
|
||||
agentTerminalHandle: string | null
|
||||
projection: { liveness: { verdict: string } }
|
||||
}[]
|
||||
}
|
||||
expect(workerList.workers).toEqual([
|
||||
@@ -167,12 +168,18 @@ describe('manual Dispatch observation', () => {
|
||||
})
|
||||
])
|
||||
|
||||
await expect(
|
||||
call('orchestration.workerShow', { dispatch: dispatch.id })
|
||||
).resolves.toMatchObject({
|
||||
worker: { state: 'unsupervised', stage: 'injected', agent_terminal_handle: 'term_worker' },
|
||||
const workerShow = (await call('orchestration.workerShow', {
|
||||
dispatch: dispatch.id
|
||||
})) as { projection: { liveness: { verdict: string } } | null }
|
||||
expect(workerShow).toMatchObject({
|
||||
worker: { state: 'unsupervised', stage: 'injected', agentTerminalHandle: 'term_worker' },
|
||||
observation: { status: 'live', exactWorker: true }
|
||||
})
|
||||
// Why: worker-show published only PTY liveness, so it read `live` for a dispatch that
|
||||
// worker-list called `unverifiable` — and worker-list's nextAction sent you back here.
|
||||
expect(workerShow.projection?.liveness.verdict).toBe(
|
||||
workerList.workers[0].projection.liveness.verdict
|
||||
)
|
||||
await expect(
|
||||
call('orchestration.workerRead', { dispatch: dispatch.id, source: 'terminal' })
|
||||
).resolves.toMatchObject({
|
||||
|
||||
@@ -6,9 +6,12 @@ import { defineMethod, type RpcMethod } from '../core'
|
||||
import { OptionalFiniteNumber, requiredString } from '../schemas'
|
||||
import {
|
||||
callFederatedWorkerShow,
|
||||
exposeDispatchContext,
|
||||
exposeFederatedWorkerObservation,
|
||||
exposeObservation,
|
||||
exposeWorker,
|
||||
inspectWorkerTerminal,
|
||||
projectFleetWorker,
|
||||
resolvePinnedFederatedServer,
|
||||
showContextOnlyWorker
|
||||
} from './orchestration-worker-observation'
|
||||
@@ -118,8 +121,9 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [
|
||||
)
|
||||
}
|
||||
return {
|
||||
dispatch: db.getDispatchContextById(params.dispatch),
|
||||
dispatch: exposeDispatchContext(db.getDispatchContextById(params.dispatch) ?? dispatch),
|
||||
worker: exposeWorker(worker),
|
||||
projection: projectFleetWorker(runtime, db, params.dispatch),
|
||||
server: { environmentId: server.environmentId, name: server.name },
|
||||
remoteRuntimeEpoch:
|
||||
db.getFederatedDispatch(params.dispatch)?.remote_runtime_epoch ??
|
||||
@@ -148,19 +152,12 @@ export const ORCHESTRATION_WORKER_CONTROL_METHODS: RpcMethod[] = [
|
||||
const observation = await inspectWorkerTerminal(runtime, db, params.dispatch)
|
||||
const resource = db.getWorkerTerminalResourceByOwner(params.dispatch)
|
||||
return {
|
||||
dispatch,
|
||||
dispatch: exposeDispatchContext(dispatch),
|
||||
worker: exposeWorker(worker),
|
||||
// Why: the fleet verdict, so worker-show and worker-list cannot disagree.
|
||||
projection: projectFleetWorker(runtime, db, params.dispatch),
|
||||
terminal: observation.exact ? observation.terminal : null,
|
||||
observation: {
|
||||
status: observation.status,
|
||||
exactWorker: observation.exact,
|
||||
// Why: a bare `unverifiable` is not actionable without naming what we lost.
|
||||
...(observation.reason ? { reason: observation.reason } : {}),
|
||||
// Why conditional: a present null must mean "looked, nothing waiting". An
|
||||
// unattached, missing or identity-changed worker was never looked at, and saying
|
||||
// null there is the false negative this field exists to remove.
|
||||
...(observation.agentWait !== undefined ? { agentWait: observation.agentWait } : {})
|
||||
},
|
||||
observation: exposeObservation(observation),
|
||||
terminalResource: resource ? exposeWorkerTerminalResource(resource) : null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,7 +283,15 @@ async function projectWorkerListPageWithFilteredSnapshot(
|
||||
agentTerminalHandle: row.agentTerminalHandle,
|
||||
terminalState: row.terminalState,
|
||||
resource: row.resource ? exposeWorkerTerminalResource(row.resource) : null,
|
||||
projection
|
||||
// Why: `projection.resource` restated id/ownerDispatchId/releaseState/terminalState
|
||||
// that the row already carries; only the derived ownership classification is new.
|
||||
projection: {
|
||||
...projection,
|
||||
resource:
|
||||
projection.resource.state === 'absent'
|
||||
? projection.resource
|
||||
: { state: projection.resource.state }
|
||||
}
|
||||
}
|
||||
})
|
||||
const counts = Object.fromEntries(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../orchestration/db'
|
||||
import { inspectWorkerTerminal } from './orchestration-worker-observation'
|
||||
import {
|
||||
exposeDispatchContext,
|
||||
exposeWorker,
|
||||
inspectWorkerTerminal
|
||||
} from './orchestration-worker-observation'
|
||||
import type { DispatchContextRow, WorkerDispatchRow } from '../../orchestration/types'
|
||||
|
||||
const DISPATCH_ID = 'ctx-worker'
|
||||
const TERMINAL_HANDLE = 'term-worker'
|
||||
@@ -63,3 +68,58 @@ describe('inspectWorkerTerminal missing liveness verdict', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('worker-show receipt shape', () => {
|
||||
it('parses the JSON columns once and emits one casing', () => {
|
||||
const exposed = exposeWorker({
|
||||
dispatch_id: DISPATCH_ID,
|
||||
runtime_epoch: 'epoch-1',
|
||||
state: 'ready',
|
||||
stage: 'input_accepted',
|
||||
worktree_id: 'repo::/tmp/wt',
|
||||
agent_terminal_handle: TERMINAL_HANDLE,
|
||||
setup_state: 'ran',
|
||||
effects: '[{"kind":"setup"}]',
|
||||
residual_resources: '["res-1"]',
|
||||
start_options: '{"agent":"codex"}',
|
||||
last_error: null,
|
||||
created_at: 'now',
|
||||
updated_at: 'now'
|
||||
} as WorkerDispatchRow)
|
||||
|
||||
expect(exposed).toEqual({
|
||||
dispatchId: DISPATCH_ID,
|
||||
runtimeEpoch: 'epoch-1',
|
||||
state: 'ready',
|
||||
stage: 'input_accepted',
|
||||
worktreeId: 'repo::/tmp/wt',
|
||||
agentTerminalHandle: TERMINAL_HANDLE,
|
||||
setupState: 'ran',
|
||||
effects: [{ kind: 'setup' }],
|
||||
residualResources: ['res-1'],
|
||||
startOptions: { agent: 'codex' },
|
||||
lastError: null,
|
||||
createdAt: 'now',
|
||||
updatedAt: 'now'
|
||||
})
|
||||
})
|
||||
|
||||
it('parses host_scope and withholds authority hashes from the dispatch row', () => {
|
||||
const exposed = exposeDispatchContext({
|
||||
id: DISPATCH_ID,
|
||||
run_id: 'run-1',
|
||||
task_id: 'task-1',
|
||||
launch_token_hash: 'launch-secret',
|
||||
capability_hash: 'capability-secret',
|
||||
host_scope: JSON.stringify({ kind: 'local', hostId: 'local' })
|
||||
} as DispatchContextRow)
|
||||
|
||||
expect(exposed).toMatchObject({
|
||||
id: DISPATCH_ID,
|
||||
hostScope: { kind: 'local', hostId: 'local' }
|
||||
})
|
||||
expect(exposed).not.toHaveProperty('host_scope')
|
||||
expect(exposed).not.toHaveProperty('launch_token_hash')
|
||||
expect(exposed).not.toHaveProperty('capability_hash')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { OrcaRuntimeService } from '../../orca-runtime'
|
||||
import type { OrchestrationDb } from '../../orchestration/db'
|
||||
import { OrchestrationError } from '../../orchestration/orchestration-error'
|
||||
import { parseWorkerTerminalHostScope } from '../../orchestration/worker-terminal-process-liveness'
|
||||
import type { OrchestrationFleetWorker } from '../../../../shared/orchestration-fleet-projection'
|
||||
import { projectWorkerFleet } from './orchestration-worker-list-projection'
|
||||
import type {
|
||||
DispatchContextRow,
|
||||
FederatedDispatchRow,
|
||||
@@ -83,24 +85,46 @@ export async function inspectWorkerTerminal(
|
||||
}
|
||||
}
|
||||
|
||||
/** Why conditional: a present `agentWait: null` must mean "looked, nothing waiting"; an
|
||||
* unattached, missing or identity-changed worker was never looked at, and a bare
|
||||
* `unverifiable` is not actionable without naming what contact was lost. */
|
||||
export function exposeObservation(observation: Awaited<ReturnType<typeof inspectWorkerTerminal>>) {
|
||||
return {
|
||||
status: observation.status,
|
||||
exactWorker: observation.exact,
|
||||
...(observation.reason ? { reason: observation.reason } : {}),
|
||||
...(observation.agentWait !== undefined ? { agentWait: observation.agentWait } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function exposeContextOnlyWorker(dispatch: DispatchContextRow) {
|
||||
return {
|
||||
dispatch_id: dispatch.id,
|
||||
runtime_epoch: null,
|
||||
dispatchId: dispatch.id,
|
||||
runtimeEpoch: null,
|
||||
state: 'unsupervised' as const,
|
||||
stage: dispatch.capability_hash ? 'injected' : 'context_only',
|
||||
worktree_id: null,
|
||||
agent_terminal_handle: dispatch.assignee_handle,
|
||||
setup_state: 'not_applicable',
|
||||
effects: [],
|
||||
residualResources: [],
|
||||
startOptions: {},
|
||||
last_error: dispatch.last_failure,
|
||||
created_at: dispatch.created_at,
|
||||
updated_at: dispatch.completed_at ?? dispatch.created_at
|
||||
worktreeId: null,
|
||||
agentTerminalHandle: dispatch.assignee_handle,
|
||||
setupState: 'not_applicable',
|
||||
effects: [] as unknown[],
|
||||
residualResources: [] as unknown[],
|
||||
startOptions: {} as unknown,
|
||||
lastError: dispatch.last_failure,
|
||||
createdAt: dispatch.created_at,
|
||||
updatedAt: dispatch.completed_at ?? dispatch.created_at
|
||||
}
|
||||
}
|
||||
|
||||
// Why: `launch_token_hash` and `capability_hash` are authority material with no receipt
|
||||
// consumer, and `host_scope` shipped as a JSON string inside JSON. One camelCase shape.
|
||||
export function exposeDispatchContext(dispatch: DispatchContextRow) {
|
||||
const exposed: Partial<DispatchContextRow> & { hostScope?: unknown } = { ...dispatch }
|
||||
delete exposed.launch_token_hash
|
||||
delete exposed.capability_hash
|
||||
delete exposed.host_scope
|
||||
return { ...exposed, hostScope: parseWorkerTerminalHostScope(dispatch.host_scope) }
|
||||
}
|
||||
|
||||
export async function showContextOnlyWorker(
|
||||
runtime: OrcaRuntimeService,
|
||||
db: OrchestrationDb,
|
||||
@@ -108,28 +132,64 @@ export async function showContextOnlyWorker(
|
||||
) {
|
||||
const observation = await inspectWorkerTerminal(runtime, db, dispatch.id)
|
||||
return {
|
||||
dispatch,
|
||||
dispatch: exposeDispatchContext(dispatch),
|
||||
worker: exposeContextOnlyWorker(dispatch),
|
||||
projection: projectFleetWorker(runtime, db, dispatch.id),
|
||||
terminal: observation.exact ? observation.terminal : null,
|
||||
observation: {
|
||||
status: observation.status,
|
||||
exactWorker: observation.exact,
|
||||
...(observation.reason ? { reason: observation.reason } : {}),
|
||||
...(observation.agentWait !== undefined ? { agentWait: observation.agentWait } : {})
|
||||
},
|
||||
observation: exposeObservation(observation),
|
||||
terminalResource: null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: the row was spread verbatim beside its parsed copies, so a reader got
|
||||
// `residual_resources` (a JSON string) next to `residualResources` (an array) and had to
|
||||
// guess which was authoritative. Parse once, emit camelCase once.
|
||||
export function exposeWorker(worker: WorkerDispatchRow) {
|
||||
return {
|
||||
...worker,
|
||||
dispatchId: worker.dispatch_id,
|
||||
runtimeEpoch: worker.runtime_epoch,
|
||||
state: worker.state,
|
||||
stage: worker.stage,
|
||||
worktreeId: worker.worktree_id,
|
||||
agentTerminalHandle: worker.agent_terminal_handle,
|
||||
setupState: worker.setup_state,
|
||||
effects: JSON.parse(worker.effects) as unknown[],
|
||||
residualResources: JSON.parse(worker.residual_resources) as unknown[],
|
||||
startOptions: JSON.parse(worker.start_options) as unknown
|
||||
startOptions: JSON.parse(worker.start_options) as unknown,
|
||||
lastError: worker.last_error,
|
||||
createdAt: worker.created_at,
|
||||
updatedAt: worker.updated_at
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same fleet verdict `worker-list` publishes, for one Dispatch.
|
||||
*
|
||||
* Why worker-show needs it: `observation.status` is PTY liveness, so an agent that died
|
||||
* at a trust prompt inside a live pane read `live` here and `unverifiable` from
|
||||
* `worker-list` — and `worker-list`'s own `nextAction` pointed back at this command.
|
||||
*/
|
||||
export function projectFleetWorker(
|
||||
runtime: OrcaRuntimeService,
|
||||
db: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): OrchestrationFleetWorker | null {
|
||||
const rows = db.listWorkerTerminalResources({ dispatchIds: [dispatchId], limit: 1 })
|
||||
if (rows.length === 0) {
|
||||
return null
|
||||
}
|
||||
const now = Date.now()
|
||||
return (
|
||||
projectWorkerFleet({
|
||||
rows,
|
||||
attentionFacts: db.getWorkerAttentionFactsForDispatches([dispatchId], now),
|
||||
statuses: runtime.getOrchestrationFleetAgentStatusSnapshot(),
|
||||
limit: 1,
|
||||
now
|
||||
}).workers[0] ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function exposeFederatedWorkerObservation(
|
||||
observation: { status?: string; exactWorker: boolean; reason?: string },
|
||||
projected: boolean
|
||||
|
||||
@@ -290,7 +290,7 @@ describe('orchestration worker recovery', () => {
|
||||
await expect(
|
||||
call('orchestration.workerShow', { dispatch: started.dispatch.id })
|
||||
).resolves.toMatchObject({
|
||||
worker: { state: 'stopped', stage: 'process_stopped', last_error: null },
|
||||
worker: { state: 'stopped', stage: 'process_stopped', lastError: null },
|
||||
observation: { status: 'exited', exactWorker: true }
|
||||
})
|
||||
expect(db.getTask(task.id)?.status).toBe('blocked')
|
||||
@@ -370,7 +370,7 @@ describe('orchestration worker recovery', () => {
|
||||
})
|
||||
|
||||
await expect(show).resolves.toMatchObject({
|
||||
worker: { stage: 'released', agent_terminal_handle: null },
|
||||
worker: { stage: 'released', agentTerminalHandle: null },
|
||||
remoteRuntimeEpoch: 'windows_epoch_new',
|
||||
terminal: null,
|
||||
observation: {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describeUnconfirmedAgentStop, describeUnconfirmedStop } from './pty-liveness-verdict'
|
||||
|
||||
describe('unconfirmed-stop sentences', () => {
|
||||
it('terminates a reason that has no terminator', () => {
|
||||
expect(describeUnconfirmedStop('its SSH provider is no longer registered')).toBe(
|
||||
'The PTY was not confirmed stopped: its SSH provider is no longer registered.'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not double the terminator on a reason that is already a sentence', () => {
|
||||
// A relayed lifecycle_conflict message arrives punctuated and printed `...to failed..`.
|
||||
expect(
|
||||
describeUnconfirmedAgentStop({
|
||||
ptyStopVerdict: 'unverifiable',
|
||||
ptyStopReason: 'worker w1 cannot transition from stopping to failed.'
|
||||
})
|
||||
).toBe(
|
||||
'The agent terminal was closed but its process could not be confirmed stopped: worker w1 cannot transition from stopping to failed.'
|
||||
)
|
||||
})
|
||||
|
||||
it('still terminates the live-process wording', () => {
|
||||
expect(describeUnconfirmedAgentStop({ ptyStopVerdict: 'live' })).toBe(
|
||||
'The agent terminal was closed but its process could not be confirmed stopped: it is live.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -16,9 +16,15 @@ export const NO_OBSERVING_PROVIDER_REASON = 'no registered provider can observe
|
||||
export const SSH_EXIT_UNCONFIRMED_REASON = 'the owning SSH host did not confirm the PTY exit'
|
||||
export const PTY_LIVE_NOTE = 'The PTY is live.'
|
||||
|
||||
// Why: reasons reach these sentences from verdicts, receipts and relayed errors, and
|
||||
// some already end in a terminator — appending one blindly printed `...to failed..`.
|
||||
function endSentence(detail: string): string {
|
||||
return /[.!?]$/u.test(detail.trimEnd()) ? detail.trimEnd() : `${detail.trimEnd()}.`
|
||||
}
|
||||
|
||||
/** The one sentence every surface uses to admit a stop was not confirmed. */
|
||||
export function describeUnconfirmedStop(reason: string): string {
|
||||
return `The PTY was not confirmed stopped: ${reason}.`
|
||||
return `The PTY was not confirmed stopped: ${endSentence(reason)}`
|
||||
}
|
||||
|
||||
/** Words a close whose PTY teardown was never confirmed, for a stop receipt. */
|
||||
@@ -30,5 +36,5 @@ export function describeUnconfirmedAgentStop(close: {
|
||||
close.ptyStopVerdict === 'live'
|
||||
? 'it is live'
|
||||
: (close.ptyStopReason ?? 'the stop outcome could not be verified')
|
||||
return `The agent terminal was closed but its process could not be confirmed stopped: ${detail}.`
|
||||
return `The agent terminal was closed but its process could not be confirmed stopped: ${endSentence(detail)}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user