Files
orca/src/main/runtime/rpc/methods/orchestration-dispatch-error-codes.test.ts
T
Jinwoo Hong 54a8afc91d fix(orchestration): typed error codes for dispatch and worker-start refusals (#18902)
* fix(orchestration): typed error codes for dispatch and worker-start refusals

orchestration dispatch (and worker-start, which composes it) surfaced task
not found, task not ready, and inject rejected as the same bare
runtime_error, so an agent reading the receipt could not choose between
creating the task, waiting on dependencies, or picking another terminal.

Add task_not_found (data.taskId), task_not_ready (data.status,
data.unmetDependencies), and inject_rejected (data.terminal, data.reason),
each carrying data.nextSteps so every shipped CLI already prints the
recovery. worker-start's not-ready refusal moves from task_not_startable
to task_not_ready with the same detail. runtime_error stays for genuinely
unexpected failures.

Proven red-first from RpcDispatcher through the CLI's own failure
formatting, plus an SSH bridge test that the host CLI's typed refusal
relays unchanged.

* test(orchestration): load CLI formatter at runtime in the dispatch-code test

The composite node typecheck (config/tsconfig.node.json without
--composite false, as CI runs it) rejects a static import of src/cli from
a main test with TS6307. Load the formatter and error class dynamically
behind narrow structural types, as the CLI/runtime boundary test does.

* fix(orchestration): keep task_not_startable and split the CLI-format proof

Review on #18902:

- Drop task_not_ready. worker-start already published task_not_startable
  for a not-ready Task, so renaming it would change an existing receipt
  value under old clients. dispatch now emits task_not_startable too (it was
  a bare runtime_error before, so this is purely additive), with the new
  data.status / data.unmetDependencies / data.nextSteps.
- Move the refusal receipts (code, message, data) into
  src/shared/orchestration-dispatch-refusal-contract.ts so the runtime
  emits them and the CLI test formats the identical envelope. The RPC test
  under src/main asserts toEqual against the contract; the new
  src/cli/orchestration-dispatch-refusal-format.test.ts feeds those same
  receipts to formatCliError / reportCliError. Neither tsconfig widens and
  the composite typecheck CI runs is clean.

* fix(orchestration): keep published refusal messages and type the DB claim guards

Codex review of #18902:

- Every call site keeps the exact message it published on main
  ("Task not found: <id>", "only a ready Task can start.", "cannot retry
  from Dispatch"); the shared contract now takes the message per site and
  only owns the code and data. Baseline strings are pinned as literals.
- createDispatchContext's own missing/non-ready guards, including the
  atomic-claim loser, now emit the same typed receipt instead of a bare
  Error, so a dispatch that races a status change no longer flattens to
  runtime_error. Covered by a dispatcher-level race test.
- Invalid --retry-of keeps task_not_startable but now carries status,
  unmetDependencies, retryOf, and a retry-specific next step.
- Dependency recovery text distinguishes waiting on running deps from
  retrying/unblocking failed ones.
- CLI test adds an unknown-code case so the old-client claim rests on an
  assertion, not a comment; SSH test asserts exact stdout.
- Guide table narrowed to the covered preflight cases; occupancy stays
  runtime_error and is named as such.
2026-09-05 20:27:29 -04:00

243 lines
8.9 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
import {
buildInjectRejectionMessage,
injectRejectedRefusal,
taskNotFoundRefusal,
taskNotStartableRefusal
} from '../../../../shared/orchestration-dispatch-refusal-contract'
import { OrcaRuntimeService } from '../../orca-runtime'
import { OrchestrationDb } from '../../orchestration/db'
import type { RpcFailure, RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { ORCHESTRATION_METHODS } from './orchestration'
const COORDINATOR_HANDLE = 'term_codes_coordinator'
const COORDINATOR_PANE = 'tab_coord:cccccccc-cccc-4ccc-8ccc-cccccccccccc'
const WORKER_HANDLE = 'term_codes_worker'
const WORKER_PANE = 'tab_worker:dddddddd-dddd-4ddd-8ddd-dddddddddddd'
type Harness = { db: OrchestrationDb; runtime: OrcaRuntimeService; dispatcher: RpcDispatcher }
const harnesses: Harness[] = []
let requestSequence = 0
afterEach(() => {
for (const harness of harnesses.splice(0)) {
harness.db.close()
}
vi.restoreAllMocks()
})
// Why: an agent reads the receipt code to pick a recovery; every case is driven from the real
// RPC dispatcher and checked against the shared contract the CLI-side test formats.
describe('orchestration dispatch failure codes through RpcDispatcher', () => {
it('reports task_not_found for a task id that does not exist', async () => {
const harness = createHarness()
const response = await dispatch(harness, { task: 'task_missing', to: WORKER_HANDLE })
expect(expectFailure(response).error).toEqual(
taskNotFoundRefusal('Task not found: task_missing', { taskId: 'task_missing' })
)
})
it('reports task_not_startable with the unmet dependencies for a pending task', async () => {
const harness = createHarness()
const parent = harness.db.createTask({ spec: 'parent' })
const child = harness.db.createTask({ spec: 'child', deps: [parent.id] })
const response = await dispatch(harness, { task: child.id, to: WORKER_HANDLE })
expect(expectFailure(response).error).toEqual(
taskNotStartableRefusal(`Task ${child.id} is pending; only ready tasks can be dispatched`, {
taskId: child.id,
status: 'pending',
unmetDependencies: [parent.id]
})
)
expect(harness.db.getTask(child.id)?.status).toBe('pending')
})
it('reports task_not_startable with the status for a completed task', async () => {
const harness = createHarness()
const task = harness.db.createTask({ spec: 'done' })
harness.db.updateTaskStatus(task.id, 'completed')
const response = await dispatch(harness, { task: task.id, to: WORKER_HANDLE })
expect(expectFailure(response).error).toEqual(
taskNotStartableRefusal(`Task ${task.id} is completed; only ready tasks can be dispatched`, {
taskId: task.id,
status: 'completed',
unmetDependencies: []
})
)
})
it('reports inject_rejected when the target terminal runs no recognized agent', async () => {
const harness = createHarness()
const task = harness.db.createTask({ spec: 'work' })
vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockResolvedValue(false)
const response = await dispatch(harness, { task: task.id, to: WORKER_HANDLE, inject: true })
expect(expectFailure(response).error).toEqual(
injectRejectedRefusal(WORKER_HANDLE, 'no_agent_detected')
)
expect(expectFailure(response).error.message).toBe(buildInjectRejectionMessage(WORKER_HANDLE))
expect(harness.db.getTask(task.id)?.status).toBe('ready')
expect(harness.db.getDispatchContext(task.id)).toBeUndefined()
})
it('reports task_not_startable with dependency detail from worker-start', async () => {
const harness = createHarness()
const parent = harness.db.createTask({ spec: 'parent' })
const child = harness.db.createTask({ spec: 'child', deps: [parent.id] })
mockWorkerStartTopology(harness.runtime)
const response = await harness.dispatcher.dispatch(
request('orchestration.workerStart', {
task: child.id,
from: COORDINATOR_HANDLE,
agent: 'claude'
})
)
expect(expectFailure(response).error).toEqual(
taskNotStartableRefusal(`Task ${child.id} is pending; only a ready Task can start.`, {
taskId: child.id,
status: 'pending',
unmetDependencies: [parent.id]
})
)
expect(harness.db.getTask(child.id)?.status).toBe('pending')
})
it('reports task_not_startable with retry detail for an invalid --retry-of', async () => {
const harness = createHarness()
const task = harness.db.createTask({ spec: 'work' })
mockWorkerStartTopology(harness.runtime)
const response = await harness.dispatcher.dispatch(
request('orchestration.workerStart', {
task: task.id,
from: COORDINATOR_HANDLE,
agent: 'claude',
retryOf: 'ctx_missing'
})
)
expect(expectFailure(response).error).toEqual(
taskNotStartableRefusal(`Task ${task.id} cannot retry from Dispatch ctx_missing.`, {
taskId: task.id,
status: 'ready',
unmetDependencies: [],
retryOf: 'ctx_missing'
})
)
})
it('types the atomic claim loser when the task changes after the ready precheck', async () => {
const harness = createHarness()
const task = harness.db.createTask({ spec: 'raced' })
// Why: the pane lookup runs after the ready precheck and before the DB claim, so failing the
// task there is the interleaving a concurrent status change produces. The loser's DB refusal
// must carry the same typed receipt instead of the bare Error it used to throw.
vi.mocked(harness.runtime.getTerminalPaneKey).mockImplementation((handle) => {
if (handle === WORKER_HANDLE) {
harness.db.updateTaskStatus(task.id, 'failed', 'raced out')
return WORKER_PANE
}
return handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : null
})
const response = await dispatch(harness, { task: task.id, to: WORKER_HANDLE })
expect(expectFailure(response).error).toEqual(
taskNotStartableRefusal(`Task ${task.id} is failed; only ready tasks can be dispatched`, {
taskId: task.id,
status: 'failed',
unmetDependencies: []
})
)
expect(harness.db.getDispatchContext(task.id)).toBeUndefined()
})
it('keeps runtime_error for a genuinely unexpected dispatch failure', async () => {
const harness = createHarness()
const task = harness.db.createTask({ spec: 'work' })
vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockRejectedValue(
new Error('probe exploded')
)
const response = await dispatch(harness, { task: task.id, to: WORKER_HANDLE, inject: true })
expect(expectFailure(response).error).toMatchObject({
code: 'runtime_error',
message: 'probe exploded'
})
})
})
function expectFailure(response: RpcResponse): RpcFailure {
if (response.ok) {
throw new Error(`Expected a failure, got ${JSON.stringify(response.result)}`)
}
return response
}
function createHarness(): Harness {
const db = new OrchestrationDb(':memory:')
const runtime = new OrcaRuntimeService()
runtime.setOrchestrationDb(db)
vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) =>
handle === COORDINATOR_HANDLE ? COORDINATOR_PANE : handle === WORKER_HANDLE ? WORKER_PANE : null
)
vi.spyOn(runtime, 'getTerminalProcessIncarnation').mockImplementation((handle) =>
handle === WORKER_HANDLE ? 'pty-worker:incarnation-1' : null
)
const runId = db.createRun({
objective: 'Typed dispatch failures',
coordinatorHandle: COORDINATOR_HANDLE,
coordinatorPaneKey: COORDINATOR_PANE
}).id
const createTask = db.createTask.bind(db)
db.createTask = (task) => createTask({ ...task, runId: task.runId ?? runId })
const harness = {
db,
runtime,
dispatcher: new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS })
}
harnesses.push(harness)
return harness
}
function mockWorkerStartTopology(runtime: OrcaRuntimeService): void {
vi.spyOn(runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {})
vi.spyOn(runtime, 'showTerminal').mockImplementation(
async (handle) => ({ handle, worktreeId: 'repo::worktree', status: 'running' }) as never
)
vi.spyOn(runtime, 'showManagedTerminalWorkspace').mockResolvedValue({
id: 'repo::worktree'
} as never)
}
function dispatch(harness: Harness, params: Record<string, unknown>): Promise<RpcResponse> {
return harness.dispatcher.dispatch(
request('orchestration.dispatch', { from: COORDINATOR_HANDLE, ...params })
)
}
function request(method: string, params: Record<string, unknown>): RpcRequest {
requestSequence += 1
return {
id: `rpc_dispatch_error_codes_${requestSequence}`,
authToken: 'test-token',
method,
params,
orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION,
orchestrationRequestId: `dispatch_error_codes_${requestSequence}`
}
}