From cd4483c49b9826efece72a1ac481a3cb9729ec8c Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Mon, 4 May 2026 04:05:57 -0400 Subject: [PATCH] =?UTF-8?q?feat(orchestration):=20QoL=20bundle=20=E2=80=94?= =?UTF-8?q?=20preamble=20visibility,=20status=20enum,=20dispatch=20cross-r?= =?UTF-8?q?ef,=20inbox=20--full?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 '', 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 --- src/cli/handlers/orchestration.ts | 124 ++++++++++++++---- src/cli/index.test.ts | 25 ++++ src/cli/specs/orchestration.ts | 16 ++- src/main/runtime/orchestration/db.test.ts | 30 +++++ src/main/runtime/orchestration/db.ts | 44 +++++++ .../runtime/rpc/methods/orchestration.test.ts | 109 +++++++++++++++ src/main/runtime/rpc/methods/orchestration.ts | 85 ++++++++++-- 7 files changed, 389 insertions(+), 44 deletions(-) diff --git a/src/cli/handlers/orchestration.ts b/src/cli/handlers/orchestration.ts index be807c28a2f..31683a0eed1 100644 --- a/src/cli/handlers/orchestration.ts +++ b/src/cli/handlers/orchestration.ts @@ -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 = { }, '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 = { 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 = { '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 = { 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 = { '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.' } diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 8783968bf24..675c1105932 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -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' diff --git a/src/cli/specs/orchestration.ts b/src/cli/specs/orchestration.ts index e527fe8b953..3c2e9df3e31 100644 --- a/src/cli/specs/orchestration.ts +++ b/src/cli/specs/orchestration.ts @@ -35,8 +35,8 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [ { path: ['orchestration', 'inbox'], summary: 'Show all messages across recipients', - usage: 'orca orchestration inbox [--limit ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'limit'] + usage: 'orca orchestration inbox [--limit ] [--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 --status [--result ] [--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 --to [--from ] [--inject] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task', 'to', 'from', 'inject'] + 'orca orchestration dispatch --task --to [--from ] [--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 [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'task'] + usage: + 'orca orchestration dispatch-show --task [--preamble] [--from ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'task', 'preamble', 'from'] }, { path: ['orchestration', 'run'], diff --git a/src/main/runtime/orchestration/db.test.ts b/src/main/runtime/orchestration/db.test.ts index d412255fdae..cbf7ce532fe 100644 --- a/src/main/runtime/orchestration/db.test.ts +++ b/src/main/runtime/orchestration/db.test.ts @@ -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' }) diff --git a/src/main/runtime/orchestration/db.ts b/src/main/runtime/orchestration/db.ts index b7c74bee72e..1152ba4c62b 100644 --- a/src/main/runtime/orchestration/db.ts +++ b/src/main/runtime/orchestration/db.ts @@ -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 diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 64a2507d0f1..9e25587a78f 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -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', () => { diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 5c467e369b0..e0a1e806d86 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -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 } } }),