mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
* 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.
243 lines
8.9 KiB
TypeScript
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}`
|
|
}
|
|
}
|