feat(orchestration): QoL bundle — preamble visibility, status enum, dispatch cross-ref, inbox --full

Addresses four items from ORCHESTRATOR_FEEDBACK:

- #5 preamble visibility: `dispatch-show --preamble` regenerates the preamble
  text for a task; `dispatch --inject --dry-run` previews without mutating
  state; `dispatch --return-preamble` echoes the injected preamble in the JSON
  response so coordinators can audit what a worker received.
- #6 status enum validation: CLI rejects unknown `task-update --status` values
  with `invalid status '<x>', expected one of: pending, ready, dispatched,
  completed, failed, blocked` before the RPC's generic Zod message. Valid
  statuses are listed under Notes in `task-update --help`.
- #13 task-list dispatch cross-ref: `task-list --json` now includes
  `assignee_handle` and `dispatch_id` for tasks in status=dispatched via a
  read-only LEFT JOIN on dispatch_contexts. Non-dispatched rows keep their
  legacy shape so existing consumers are unaffected.
- #14 inbox body visibility: `inbox --full` prints body + payload verbatim;
  default output is unchanged (id/from/to/subject only).

No DB migrations; join-only change on dispatch_contexts so the sibling
preamble PR's `last_heartbeat_at` column addition will not conflict.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-04 04:27:04 -04:00
co-authored by Orca
parent 772be947d8
commit cd4483c49b
7 changed files with 389 additions and 44 deletions
+100 -24
View File
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- Why: orchestration CLI handlers share flag-parsing helpers and dispatch/preamble logic; splitting by verb would fragment the RuntimeClient call shape without reducing complexity. */
import type { CommandHandler } from '../dispatch'
import { printResult } from '../format'
import {
@@ -5,14 +6,28 @@ import {
getOptionalStringFlag,
getRequiredStringFlag
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import { getTerminalHandle } from '../selectors'
// Why: mirrors TaskStatus (orchestration/types.ts) so the CLI can surface a
// clear enum-aware error before the generic RPC Zod "Missing --status" message.
const TASK_STATUS_VALUES = [
'pending',
'ready',
'dispatched',
'completed',
'failed',
'blocked'
] as const
type MessageSummary = {
id: string
from_handle: string
to_handle?: string
subject: string
type?: string
body?: string
payload?: string | null
}
async function resolveOrchestrationTerminalHandle(
@@ -98,6 +113,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
},
'orchestration inbox': async ({ flags, client, json }) => {
const full = flags.has('full')
const result = await client.call<{
messages: MessageSummary[]
count: number
@@ -108,9 +124,24 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
if (r.count === 0) {
return 'No messages.'
}
// Why: default output omits body/payload for at-a-glance sweeps; --full
// prints them verbatim so callers can audit without parsing --json.
return r.messages
.map((m) => `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`)
.join('\n')
.map((m) => {
const head = `${m.id} ${m.from_handle} -> ${m.to_handle ?? '?'}: "${m.subject}"`
if (!full) {
return head
}
const parts = [head]
if (m.body && m.body.length > 0) {
parts.push(m.body)
}
if (m.payload) {
parts.push(`[payload] ${m.payload}`)
}
return parts.join('\n')
})
.join(full ? '\n\n' : '\n')
})
},
@@ -128,7 +159,13 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
'orchestration task-list': async ({ flags, client, json }) => {
const result = await client.call<{
tasks: { id: string; spec: string; status: string }[]
tasks: {
id: string
spec: string
status: string
assignee_handle?: string | null
dispatch_id?: string | null
}[]
count: number
}>('orchestration.taskList', {
status: getOptionalStringFlag(flags, 'status'),
@@ -138,16 +175,31 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
if (r.count === 0) {
return 'No tasks.'
}
return r.tasks.map((t) => `${t.id} [${t.status}] ${t.spec.slice(0, 60)}`).join('\n')
return r.tasks
.map((t) => {
const head = `${t.id} [${t.status}] ${t.spec.slice(0, 60)}`
if (t.status === 'dispatched' && t.assignee_handle) {
return `${head} -> ${t.assignee_handle} (${t.dispatch_id ?? '?'})`
}
return head
})
.join('\n')
})
},
'orchestration task-update': async ({ flags, client, json }) => {
const status = getRequiredStringFlag(flags, 'status')
if (!TASK_STATUS_VALUES.includes(status as (typeof TASK_STATUS_VALUES)[number])) {
throw new RuntimeClientError(
'invalid_argument',
`invalid status '${status}', expected one of: ${TASK_STATUS_VALUES.join(', ')}`
)
}
const result = await client.call<{ task: { id: string; status: string } }>(
'orchestration.taskUpdate',
{
id: getRequiredStringFlag(flags, 'id'),
status: getRequiredStringFlag(flags, 'status'),
status,
result: getOptionalStringFlag(flags, 'result')
}
)
@@ -156,29 +208,53 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
'orchestration dispatch': async ({ flags, client, cwd, json }) => {
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call<{
dispatch: { id: string; task_id: string; status: string }
}>('orchestration.dispatch', {
task: getRequiredStringFlag(flags, 'task'),
to: getRequiredStringFlag(flags, 'to'),
from,
inject: flags.has('inject') ? true : undefined,
devMode: isDevCliInvocation()
})
printResult(
result,
json,
(r) => `Dispatched ${r.dispatch.task_id} -> ${r.dispatch.id} [${r.dispatch.status}]`
)
},
'orchestration dispatch-show': async ({ flags, client, json }) => {
const dryRun = flags.has('dry-run') ? true : undefined
const returnPreamble = flags.has('return-preamble') ? true : undefined
// Why: --to is only required for non-dry-run; the RPC handler re-enforces.
const to = dryRun ? getOptionalStringFlag(flags, 'to') : getRequiredStringFlag(flags, 'to')
const result = await client.call<{
dispatch: { id: string; task_id: string; status: string } | null
}>('orchestration.dispatchShow', {
task: getRequiredStringFlag(flags, 'task')
injected?: boolean
dryRun?: boolean
preamble?: string
}>('orchestration.dispatch', {
task: getRequiredStringFlag(flags, 'task'),
to,
from,
inject: flags.has('inject') ? true : undefined,
dryRun,
returnPreamble,
devMode: isDevCliInvocation()
})
printResult(result, json, (r) => {
if (r.dryRun) {
return r.preamble ?? ''
}
const base = `Dispatched ${r.dispatch?.task_id} -> ${r.dispatch?.id} [${r.dispatch?.status}]`
return r.preamble ? `${base}\n\n--- Preamble ---\n${r.preamble}` : base
})
},
'orchestration dispatch-show': async ({ flags, client, cwd, json }) => {
const showPreamble = flags.has('preamble') ? true : undefined
// Why: resolve --from when previewing so the preamble embeds a real
// coordinator handle, matching what an actual dispatch would produce.
const from = showPreamble
? await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
: undefined
const result = await client.call<{
dispatch: { id: string; task_id: string; status: string } | null
preamble?: string
}>('orchestration.dispatchShow', {
task: getRequiredStringFlag(flags, 'task'),
preamble: showPreamble,
from,
devMode: isDevCliInvocation()
})
printResult(result, json, (r) => {
if (r.preamble && showPreamble) {
return r.preamble
}
if (!r.dispatch) {
return 'No dispatch context found.'
}
+25
View File
@@ -197,6 +197,31 @@ describe('orca cli worktree awareness', () => {
expect(logSpy).toHaveBeenCalledWith('Sent 2 messages to 2 recipients')
})
it('rejects unknown task-update status with an enum-aware error', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_coord'
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const priorExitCode = process.exitCode
await main(
['orchestration', 'task-update', '--id', 'task_x', '--status', 'complete'],
'/tmp/repo'
)
const output = [...errSpy.mock.calls, ...logSpy.mock.calls]
.flat()
.map((v) => (typeof v === 'string' ? v : JSON.stringify(v)))
.join('\n')
expect(output).toContain("invalid status 'complete'")
expect(output).toContain('pending, ready, dispatched, completed, failed, blocked')
expect(callMock).not.toHaveBeenCalled()
expect(process.exitCode).toBe(1)
// Reset exitCode so subsequent tests don't inherit the failure.
process.exitCode = priorExitCode
errSpy.mockRestore()
})
it('passes dev mode to injected orchestration dispatches', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev'
+9 -7
View File
@@ -35,8 +35,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'inbox'],
summary: 'Show all messages across recipients',
usage: 'orca orchestration inbox [--limit <n>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit']
usage: 'orca orchestration inbox [--limit <n>] [--full] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'limit', 'full']
},
{
path: ['orchestration', 'task-create'],
@@ -56,20 +56,22 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
summary: 'Update a task status',
usage:
'orca orchestration task-update --id <task_id> --status <status> [--result <json>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result']
allowedFlags: [...GLOBAL_FLAGS, 'id', 'status', 'result'],
notes: ['Valid --status values: pending, ready, dispatched, completed, failed, blocked.']
},
{
path: ['orchestration', 'dispatch'],
summary: 'Dispatch a task to a terminal',
usage:
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--inject] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject']
'orca orchestration dispatch --task <task_id> --to <handle> [--from <handle>] [--inject] [--dry-run] [--return-preamble] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject', 'dry-run', 'return-preamble']
},
{
path: ['orchestration', 'dispatch-show'],
summary: 'Show dispatch context for a task',
usage: 'orca orchestration dispatch-show --task <task_id> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task']
usage:
'orca orchestration dispatch-show --task <task_id> [--preamble] [--from <handle>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from']
},
{
path: ['orchestration', 'run'],
+30
View File
@@ -198,6 +198,36 @@ describe('OrchestrationDb', () => {
expect(d.listTasks()).toHaveLength(2)
})
it('listTasksWithDispatch joins active dispatch metadata', () => {
const d = createDb()
const ready = d.createTask({ spec: 'ready task' })
const dispatched = d.createTask({ spec: 'active task' })
const ctx = d.createDispatchContext(dispatched.id, 'term_worker')
const rows = d.listTasksWithDispatch()
const readyRow = rows.find((r) => r.id === ready.id)
const dispatchedRow = rows.find((r) => r.id === dispatched.id)
expect(readyRow?.assignee_handle).toBeNull()
expect(readyRow?.dispatch_id).toBeNull()
expect(dispatchedRow?.assignee_handle).toBe('term_worker')
expect(dispatchedRow?.dispatch_id).toBe(ctx.id)
})
it('listTasksWithDispatch does not surface completed dispatches', () => {
const d = createDb()
const task = d.createTask({ spec: 'work' })
d.createDispatchContext(task.id, 'term_worker')
d.updateTaskStatus(task.id, 'completed')
const rows = d.listTasksWithDispatch()
const row = rows.find((r) => r.id === task.id)
// Task is completed — its dispatch is terminal and should not appear as
// an "active" assignee.
expect(row?.assignee_handle).toBeNull()
expect(row?.dispatch_id).toBeNull()
})
it('supports parent_id for task decomposition', () => {
const d = createDb()
const parent = d.createTask({ spec: 'parent' })
+44
View File
@@ -232,6 +232,50 @@ export class OrchestrationDb {
return this.db.prepare('SELECT * FROM tasks ORDER BY created_at').all() as TaskRow[]
}
// Why: surfaces the active dispatch (assignee handle + dispatch context id)
// alongside each task so coordinators can answer "who is working on task X?"
// from a single query. The LEFT JOIN keeps non-dispatched tasks in the result
// with NULL assignee/dispatch fields so non-dispatched output stays stable.
// The inner subquery picks the most recent active dispatch per task to match
// the semantics of getDispatchContext for dispatched tasks.
listTasksWithDispatch(filter?: { status?: TaskStatus; ready?: boolean }): (TaskRow & {
assignee_handle: string | null
dispatch_id: string | null
})[] {
const whereClauses: string[] = []
const params: unknown[] = []
if (filter?.ready) {
whereClauses.push("t.status = 'ready'")
} else if (filter?.status) {
whereClauses.push('t.status = ?')
params.push(filter.status)
}
const where = whereClauses.length > 0 ? `WHERE ${whereClauses.join(' AND ')}` : ''
const sql = `
SELECT
t.*,
d.assignee_handle AS assignee_handle,
d.id AS dispatch_id
FROM tasks t
LEFT JOIN (
SELECT dc.*
FROM dispatch_contexts dc
INNER JOIN (
SELECT task_id, MAX(rowid) AS max_rowid
FROM dispatch_contexts
WHERE status IN ('pending', 'dispatched')
GROUP BY task_id
) latest ON latest.task_id = dc.task_id AND latest.max_rowid = dc.rowid
) d ON d.task_id = t.id
${where}
ORDER BY t.created_at
`
return this.db.prepare(sql).all(...params) as (TaskRow & {
assignee_handle: string | null
dispatch_id: string | null
})[]
}
updateTaskStatus(id: string, status: TaskStatus, result?: string): TaskRow | undefined {
const completedAt =
status === 'completed' || status === 'failed' ? new Date().toISOString() : null
@@ -369,6 +369,33 @@ describe('orchestration RPC methods', () => {
const method = findMethod('orchestration.taskList')
expect(() => method.params!.parse({ status: 'done-ish' })).toThrow()
})
it('includes assignee_handle and dispatch_id for dispatched tasks', async () => {
setup()
const t1 = db.createTask({ spec: 'ready work' })
const t2 = db.createTask({ spec: 'active work' })
const ctx = db.createDispatchContext(t2.id, 'term_worker')
const result = (await call('orchestration.taskList', {})) as {
tasks: {
id: string
status: string
assignee_handle?: string | null
dispatch_id?: string | null
}[]
}
const ready = result.tasks.find((t) => t.id === t1.id)
const dispatched = result.tasks.find((t) => t.id === t2.id)
expect(ready).toBeDefined()
expect(dispatched).toBeDefined()
// Non-dispatched tasks keep the legacy shape — no assignee/dispatch fields.
expect(ready).not.toHaveProperty('assignee_handle')
expect(ready).not.toHaveProperty('dispatch_id')
// Dispatched tasks surface the active dispatch.
expect(dispatched?.assignee_handle).toBe('term_worker')
expect(dispatched?.dispatch_id).toBe(ctx.id)
})
})
describe('orchestration.taskUpdate', () => {
@@ -496,6 +523,50 @@ describe('orchestration RPC methods', () => {
/already has an active dispatch/
)
})
it('dry-run returns the preamble without mutating state', async () => {
setup()
const task = db.createTask({ spec: 'work' })
const result = (await call('orchestration.dispatch', {
task: task.id,
to: 'term_a',
inject: true,
dryRun: true,
from: 'term_coord'
})) as {
dispatch: null
dryRun: boolean
preamble: string
injected: boolean
}
expect(result.dryRun).toBe(true)
expect(result.dispatch).toBeNull()
expect(result.injected).toBe(false)
expect(result.preamble).toContain('work')
expect(result.preamble).toContain(task.id)
expect(result.preamble).toContain('term_coord')
// Task state must not change on dry-run.
expect(db.getTask(task.id)?.status).toBe('ready')
expect(db.getDispatchContext(task.id)).toBeUndefined()
})
it('returnPreamble includes preamble in the response', async () => {
setup()
const task = db.createTask({ spec: 'work' })
const result = (await call('orchestration.dispatch', {
task: task.id,
to: 'term_a',
returnPreamble: true,
from: 'term_coord'
})) as { dispatch: { id: string }; preamble: string }
expect(result.dispatch.id).toMatch(/^ctx_/)
expect(result.preamble).toContain(task.id)
expect(result.preamble).toContain('term_coord')
})
})
describe('orchestration.dispatchShow', () => {
@@ -519,6 +590,44 @@ describe('orchestration RPC methods', () => {
expect(result.dispatch).toBeNull()
})
it('--preamble returns the preamble text', async () => {
setup()
const task = db.createTask({ spec: 'refactor auth' })
db.createDispatchContext(task.id, 'term_a')
const result = (await call('orchestration.dispatchShow', {
task: task.id,
preamble: true,
from: 'term_coord'
})) as { dispatch: { task_id: string } | null; preamble: string }
expect(result.preamble).toContain('refactor auth')
expect(result.preamble).toContain(task.id)
expect(result.preamble).toContain('term_coord')
expect(result.dispatch?.task_id).toBe(task.id)
})
it('--preamble works when no dispatch exists yet', async () => {
setup()
const task = db.createTask({ spec: 'build feature' })
const result = (await call('orchestration.dispatchShow', {
task: task.id,
preamble: true,
from: 'term_coord'
})) as { dispatch: null; preamble: string }
expect(result.dispatch).toBeNull()
expect(result.preamble).toContain('build feature')
})
it('--preamble throws for unknown task', async () => {
setup()
await expect(
call('orchestration.dispatchShow', { task: 'task_fake', preamble: true })
).rejects.toThrow('Task not found')
})
})
describe('orchestration.gateCreate', () => {
+72 -13
View File
@@ -99,14 +99,22 @@ const TaskUpdateParams = z.object({
const DispatchParams = z.object({
task: requiredString('Missing --task'),
to: requiredString('Missing --to'),
// Why: --to is only required for real dispatches. When --dry-run is set the
// caller is previewing the preamble and no terminal is targeted, so allow it
// to be absent. The handler enforces presence before any side-effecting work.
to: OptionalString,
from: OptionalString,
inject: OptionalBoolean,
dryRun: OptionalBoolean,
returnPreamble: OptionalBoolean,
devMode: OptionalBoolean
})
const DispatchShowParams = z.object({
task: OptionalString
task: OptionalString,
preamble: OptionalBoolean,
from: OptionalString,
devMode: OptionalBoolean
})
const ResetParams = z.object({
@@ -291,10 +299,21 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
params: TaskListParams,
handler: (params, { runtime }) => {
const db = runtime.getOrchestrationDb()
const tasks = db.listTasks({
// Why: listTasksWithDispatch returns the same rows as listTasks plus
// assignee_handle + dispatch_id joined in for tasks that currently have an
// active dispatch. Non-dispatched tasks get NULL for those fields, so
// consumers reading the legacy shape are unaffected.
const joined = db.listTasksWithDispatch({
status: params.status as TaskStatus,
ready: params.ready
})
const tasks = joined.map((row) => {
const { assignee_handle, dispatch_id, ...base } = row
if (base.status === 'dispatched') {
return { ...base, assignee_handle, dispatch_id }
}
return base
})
return { tasks, count: tasks.length }
}
}),
@@ -321,6 +340,27 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
if (!task) {
throw new Error(`Task not found: ${params.task}`)
}
const preamble = buildDispatchPreamble({
taskId: task.id,
taskSpec: task.spec,
coordinatorHandle: params.from ?? 'coordinator',
devMode: params.devMode
})
// Why: --inject --dry-run lets a coordinator preview the exact preamble
// text that would be injected without mutating task state or touching the
// target terminal. Skips the ready-status check so coordinators can inspect
// the preamble for already-dispatched or blocked tasks too.
if (params.dryRun) {
return { dispatch: null, injected: false, dryRun: true, preamble }
}
if (!params.to) {
throw new Error('Missing --to')
}
const to = params.to
if (task.status !== 'ready') {
throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`)
}
@@ -330,28 +370,22 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
// status and foreground process — Claude Code doesn't emit recognized OSC
// titles on startup, so title-only detection misses freshly spawned agents.
if (params.inject) {
const hasAgent = await runtime.isTerminalRunningAgent(params.to)
const hasAgent = await runtime.isTerminalRunningAgent(to)
if (!hasAgent) {
throw new Error(
`Cannot dispatch --inject to terminal ${params.to}: no recognized agent detected. ` +
`Cannot dispatch --inject to terminal ${to}: no recognized agent detected. ` +
'Start an agent CLI (e.g. claude, codex, gemini) in the terminal first, ' +
'or dispatch without --inject and send the prompt manually.'
)
}
}
const ctx = db.createDispatchContext(params.task, params.to)
const ctx = db.createDispatchContext(params.task, to)
let injected = false
if (params.inject) {
try {
const preamble = buildDispatchPreamble({
taskId: task.id,
taskSpec: task.spec,
coordinatorHandle: params.from ?? 'coordinator',
devMode: params.devMode
})
await runtime.sendTerminal(params.to, { text: preamble, enter: true })
await runtime.sendTerminal(to, { text: preamble, enter: true })
injected = true
} catch (err) {
db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err))
@@ -359,6 +393,12 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
}
}
// Why: returnPreamble is opt-in because the preamble is several hundred
// bytes and most callers don't need it in the response. Exposing it
// supports coordinators that want to log what was injected for auditing.
if (params.returnPreamble) {
return { dispatch: ctx, injected, preamble }
}
return { dispatch: ctx, injected }
}
}),
@@ -372,6 +412,25 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
throw new Error('Missing --task')
}
const ctx = db.getDispatchContext(params.task)
// Why: --preamble lets callers inspect the exact preamble text that was
// (or would be) injected for this task. The preamble is derived from the
// current task spec, so even after dispatch completes the text can be
// regenerated deterministically.
if (params.preamble) {
const task = db.getTask(params.task)
if (!task) {
throw new Error(`Task not found: ${params.task}`)
}
const preamble = buildDispatchPreamble({
taskId: task.id,
taskSpec: task.spec,
coordinatorHandle: params.from ?? 'coordinator',
devMode: params.devMode
})
return { dispatch: ctx ?? null, preamble }
}
return { dispatch: ctx ?? null }
}
}),