mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
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:
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user