fix(orchestration): complete tasks on worker_done + coordinator UX fixes (#8030)

* fix(orchestration): complete worker tasks and improve coordinator UX

* Fix orchestration lifecycle sender resolution and peek/check compat hand

- Lifecycle sends (worker_done/heartbeat) now use ORCA_TERMINAL_HANDLE
  verbatim, skipping the liveness probe and pane remint that could
  block delivery during restarts or mismatch stale-runtime assignee
  handles.
- --peek now round-trips as {peek:true, unread:false} so older runtimes
  that strip unknown params degrade to non-destructive "all" instead of
  mark-read, with client-side filtering to restore peek semantics and a
  clear error when --peek --wait can't be honored.
- Reject combined read-mode flags (--unread/--peek/--all) before calling
  the runtime.
- Distinguish suppressed (already-consumed) lifecycle messages from
  ignored ones so send doesn't wake --wait waiters for stale heartbeats.
- Fix task summary truncation to avoid splitting UTF-16 surrogate pairs
  and to not misreport whitespace normalization as truncation.

* Add shared helper to abbreviate orchestration task specs for brief listi

- Normalizes whitespace and caps spec length at 160 chars, flagging
  truncation separately from whitespace-only changes
- Truncates on UTF-16 code point boundaries to avoid splitting
  surrogate pairs and emitting malformed strings

* Add pane-key identity to worker_done/heartbeat reconciliation and server

- Records the sender's pane key on messages and dispatch contexts so
  worker_done/heartbeat ownership can be verified by the remint-stable
  pane leaf instead of the terminal handle, which is reissued across
  restarts.
- Rejects lifecycle messages from a genuinely foreign pane while still
  tolerating handle remints, tab break-outs, and older CLIs that lack
  pane identity.
- Moves task-spec abbreviation server-side (orchestration.taskList
  --brief) so full specs no longer cross SSH/relay transports, with a
  client-side fallback for older runtimes; consolidates the shared
  abbreviation helper under src/shared.
- Adds a stderr warning when a pre-peek runtime's --peek response hits
  the 100-row cap, since older unread messages may be missing.

* Isolate ORCA_PANE_KEY in CLI test beforeEach to fix leaked senderPaneKey

Co-authored-by: Orca <help@stably.ai>

* Fix pane-key remint bypassing dispatch mutual-exclusion lock

- Dispatch locking only matched on assignee_handle, so a reminted
  terminal handle (tab break-out) could open a second concurrent
  dispatch on the same pane.
- Add leaf-UUID-based pane key comparison (parsePaneKey) as a
  secondary lock, falling back to exact handle match for legacy
  rows without pane keys.

* Update orchestration skill docs for lifecycle authority and CLI flag add

- Clarify that dispatch lifecycle is tied to taskId+dispatchId verified against
  the dispatched pane, not the terminal handle, since handles can be reminted
  after restart
- Document new `check --peek`/`--all` and `task-list --brief` flags, with
  fallback guidance for older CLIs that reject them
- Note that a valid worker_done auto-completes the task/dispatch, so workers
  shouldn't also call task-update manually

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-07-12 02:15:11 -07:00
committed by GitHub
co-authored by Orca
parent ee82d66a35
commit 26934b11bf
24 changed files with 1090 additions and 133 deletions
+182 -3
View File
@@ -14,6 +14,7 @@ vi.mock('../selectors', () => ({ getTerminalHandle: getTerminalHandleMock }))
import { ORCHESTRATION_HANDLERS } from './orchestration'
import { RuntimeClientError } from '../runtime-client'
import { printResult } from '../format'
function staleHandleError(): RuntimeClientError {
return new RuntimeClientError('terminal_handle_stale', 'terminal_handle_stale')
@@ -228,7 +229,7 @@ describe('orchestration send structured payload flags', () => {
})
})
it('continues to use ORCA_TERMINAL_HANDLE as worker lifecycle sender authority', async () => {
it('sends lifecycle messages from ORCA_TERMINAL_HANDLE without a liveness probe', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env'
await invokeSend(
@@ -253,6 +254,50 @@ describe('orchestration send structured payload flags', () => {
})
})
it.each(['worker_done', 'heartbeat'] as const)(
'never probes or remints a %s sender even when a pane key is set',
async (type) => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env'
process.env.ORCA_PANE_KEY = 'tab_worker:leaf_worker'
await invokeSend(
new Map<string, string | boolean>([
['to', 'term_coord'],
['subject', 'update'],
['type', type]
])
)
// Why: pre-payload-authority runtimes only complete a worker_done whose
// sender equals the recorded (equally stale) assignee handle, and
// coordinator replies route to the sender row the worker's env-handle
// `check` actually reads — so lifecycle sends must stay env-verbatim.
expect(callMock).toHaveBeenCalledTimes(1)
expect(callMock).toHaveBeenCalledWith(
'orchestration.send',
expect.objectContaining({ from: 'term_worker_env' })
)
}
)
it('passes ORCA_PANE_KEY as the sender pane identity', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker_env'
process.env.ORCA_PANE_KEY = 'tab_worker:leaf_worker'
await invokeSend(
new Map<string, string | boolean>([
['to', 'term_coord'],
['subject', 'done'],
['type', 'worker_done']
])
)
expect(callMock).toHaveBeenCalledWith(
'orchestration.send',
expect.objectContaining({ senderPaneKey: 'tab_worker:leaf_worker' })
)
})
it('reports sender resolution failure instead of raw no_active_terminal', async () => {
getTerminalHandleMock.mockRejectedValue(
new RuntimeClientError('no_active_terminal', 'no_active_terminal')
@@ -646,20 +691,24 @@ describe('orchestration timeout flag validation', () => {
expect(callMock).not.toHaveBeenCalled()
})
it('passes a parsed check timeout into the RPC payload', async () => {
it('passes a parsed check timeout and peek mode into the RPC payload', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
callMock.mockResolvedValue({ result: { messages: [], count: 0 } })
await invokeCheck(
new Map<string, string | boolean>([
['wait', true],
['peek', true],
['timeout-ms', '250']
])
)
// Why: --peek rides with unread:false so pre-peek runtimes fall back to
// the non-consuming all mode instead of the destructive mark-read default.
expect(callMock).toHaveBeenCalledWith('orchestration.check', {
terminal: 'term_worker',
unread: undefined,
unread: false,
peek: true,
all: undefined,
types: undefined,
inject: undefined,
@@ -668,6 +717,86 @@ describe('orchestration timeout flag validation', () => {
})
})
it('filters already-read rows from a peek response for pre-peek runtimes', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
callMock.mockResolvedValue({
result: {
messages: [
{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 },
{ id: 'msg_new', from_handle: 'a', subject: 'fresh', read: 0 }
],
count: 2,
formatted: 'banners built from all rows'
}
})
vi.mocked(printResult).mockClear()
await invokeCheck(new Map<string, string | boolean>([['peek', true]]))
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
result: { messages: { id: string }[]; count: number; formatted?: string }
}
expect(response.result.messages.map((m) => m.id)).toEqual(['msg_new'])
expect(response.result.count).toBe(1)
// Why: the pre-peek runtime built `formatted` from all rows, including
// the read one the filter just removed.
expect(response.result.formatted).toBeUndefined()
})
it('rejects combined read modes before calling the runtime', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
callMock.mockClear()
await expect(
invokeCheck(
new Map<string, string | boolean>([
['unread', true],
['peek', true]
])
)
).rejects.toMatchObject({
code: 'invalid_argument',
message: expect.stringContaining('read mode')
})
expect(callMock).not.toHaveBeenCalled()
})
it('warns when a pre-peek runtime returned a full 100-row page', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
const rows = Array.from({ length: 100 }, (_, i) => ({
id: `msg_${i}`,
from_handle: 'a',
subject: `s${i}`,
read: i === 0 ? 0 : 1
}))
callMock.mockResolvedValue({ result: { messages: rows, count: 100 } })
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
await invokeCheck(new Map<string, string | boolean>([['peek', true]]))
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('newest 100 messages'))
errorSpy.mockRestore()
})
it('fails --peek --wait against a runtime that returned only read rows', async () => {
process.env.ORCA_TERMINAL_HANDLE = 'term_worker'
callMock.mockResolvedValue({
result: {
messages: [{ id: 'msg_old', from_handle: 'a', subject: 'seen', read: 1 }],
count: 1
}
})
await expect(
invokeCheck(
new Map<string, string | boolean>([
['peek', true],
['wait', true]
])
)
).rejects.toMatchObject({ code: 'peek_wait_unsupported' })
})
it.each(invalidTimeoutValues)('rejects invalid ask --timeout-ms: %s', async (_label, value) => {
const flags = new Map<string, string | boolean>([
['to', 'term_coord'],
@@ -712,3 +841,53 @@ describe('orchestration timeout flag validation', () => {
)
})
})
describe('orchestration task-list brief output', () => {
it('requests server-side brief and falls back client-side for older runtimes', async () => {
callMock.mockReset().mockResolvedValue({
result: {
// No spec_truncated field — the pre-brief-runtime signature.
tasks: [{ id: 'task_1', spec: `First line\n${'detail '.repeat(40)}`, status: 'ready' }],
count: 1
}
})
vi.mocked(printResult).mockClear()
await ORCHESTRATION_HANDLERS['orchestration task-list']({
flags: new Map([['brief', true]]),
client: { call: callMock },
json: true
} as never)
expect(callMock).toHaveBeenCalledWith(
'orchestration.taskList',
expect.objectContaining({ brief: true })
)
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
result: { tasks: { spec: string; spec_truncated: boolean }[] }
}
expect(response.result.tasks[0].spec).toHaveLength(160)
expect(response.result.tasks[0].spec_truncated).toBe(true)
})
it('passes server-abbreviated rows through untouched', async () => {
const serverTasks = [
{ id: 'task_1', spec: 'already brief…', status: 'ready', spec_truncated: true }
]
callMock.mockReset().mockResolvedValue({ result: { tasks: serverTasks, count: 1 } })
vi.mocked(printResult).mockClear()
await ORCHESTRATION_HANDLERS['orchestration task-list']({
flags: new Map([['brief', true]]),
client: { call: callMock },
json: true
} as never)
const response = vi.mocked(printResult).mock.calls[0]?.[0] as {
result: { tasks: { spec: string; spec_truncated: boolean }[] }
}
// Why: re-abbreviating a server-truncated spec would flip spec_truncated
// back to false (the truncated text fits the cap).
expect(response.result.tasks).toBe(serverTasks)
})
})
+97 -26
View File
@@ -8,12 +8,13 @@ import {
} from '../flags'
import { RuntimeClientError } from '../runtime-client'
import { getTerminalHandle } from '../selectors'
import { abbreviateOrchestrationTasks } from '../../shared/orchestration-task-summary'
// Why: 15 s is well under Claude Code's empirical ~2 min Bash-tool silence
// budget and generates only ~40 lines per 10 min wait — enough to assure the
// parent process the subprocess is alive without flooding logs. See design
// doc §3.4.
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000
const DEFAULT_KEEPALIVE_INTERVAL_MS = 15_000
function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): string {
return `${type} messages must be sent to a concrete coordinator terminal handle, not a group address.`
}
@@ -21,33 +22,36 @@ function getLifecycleGroupRecipientError(type: 'worker_done' | 'heartbeat'): str
// Why: test-only escape hatch so subprocess tests can verify the feature in
// under 10 s rather than needing a full 15 s silence window. Production users
// should never set this — there is no surface documentation. A bogus value
// falls back to the default rather than disabling the heartbeat.
function resolveHeartbeatIntervalMs(): number {
const raw = process.env.ORCA_HEARTBEAT_INTERVAL_MS
// falls back to the default rather than disabling the keepalive.
function resolveKeepaliveIntervalMs(): number {
const raw = process.env.ORCA_KEEPALIVE_INTERVAL_MS ?? process.env.ORCA_HEARTBEAT_INTERVAL_MS
if (!raw) {
return DEFAULT_HEARTBEAT_INTERVAL_MS
return DEFAULT_KEEPALIVE_INTERVAL_MS
}
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) {
return DEFAULT_HEARTBEAT_INTERVAL_MS
return DEFAULT_KEEPALIVE_INTERVAL_MS
}
return parsed
}
function startCheckHeartbeat(deadlineMs: number | undefined): () => void {
function startCheckKeepalive(deadlineMs: number | undefined): () => void {
const startedAt = Date.now()
const interval = setInterval(() => {
const payload = {
_keepalive: true,
// Why: retain the old marker for scripts filtering merged stderr while
// callers migrate to the unambiguous _keepalive field.
_heartbeat: true,
elapsedMs: Date.now() - startedAt,
deadlineMs: deadlineMs ?? null
}
// Why: `process.stderr.write` is line-flushed per-call in Node, whereas a
// fully-buffered writer would hold all heartbeat lines until exit and
// fully-buffered writer would hold all keepalive lines until exit and
// silently defeat the whole point of the ping. Subprocess test asserts
// this by reading stderr incrementally. See §3.4.
process.stderr.write(`${JSON.stringify(payload)}\n`)
}, resolveHeartbeatIntervalMs())
}, resolveKeepaliveIntervalMs())
if (typeof interval.unref === 'function') {
interval.unref()
}
@@ -73,6 +77,7 @@ type MessageSummary = {
type?: string
body?: string
payload?: string | null
read?: number
}
function getOptionalStructuredMessagePayload(
@@ -142,7 +147,11 @@ async function resolveOrchestrationTerminalHandle(
// coordinator preambles.
const live = await isLiveTerminalHandle(envHandle, client)
if (!live) {
return await resolveStaleOrchestrationSender(client)
const reminted = await resolveOrchestrationPaneTerminalHandle(client)
if (reminted) {
return reminted
}
throwNoActiveSenderTerminal()
}
}
return envHandle
@@ -268,16 +277,6 @@ function getClientErrorMessage(err: unknown): string | undefined {
return typeof message === 'string' ? message : undefined
}
async function resolveStaleOrchestrationSender(
client: Parameters<CommandHandler>[0]['client']
): Promise<string> {
const paneHandle = await resolveOrchestrationPaneTerminalHandle(client)
if (paneHandle) {
return paneHandle
}
throwNoActiveSenderTerminal()
}
async function resolveCoordinatorTerminalHandle(
flags: Map<string, string | boolean>,
cwd: string,
@@ -348,6 +347,12 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
const type = getOptionalStringFlag(flags, 'type')
rejectLifecycleGroupRecipient(type, to)
// Why: lifecycle senders keep ORCA_TERMINAL_HANDLE verbatim — no liveness
// probe (terminal.show throws runtime_unavailable in the exact mid-restart
// window worker_done must survive) and no pane remint (pre-payload-
// authority runtimes require from === the equally stale assignee_handle,
// and coordinator replies route to the sender row while the worker's own
// `check` reads its env-handle inbox).
const from = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'from')
const result = await client.call<
{ message: { id: string } } | { messages: { id: string }[]; recipients: number }
@@ -360,6 +365,9 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
priority: getOptionalStringFlag(flags, 'priority'),
threadId: getOptionalStringFlag(flags, 'thread-id'),
payload: getOptionalStructuredMessagePayload(flags),
// Why: the pane key is the remint-stable sender identity the runtime
// verifies lifecycle ownership against; older runtimes strip it.
senderPaneKey: process.env.ORCA_PANE_KEY || undefined,
devMode: isDevCliInvocation()
})
printResult(result, json, (r) => {
@@ -372,17 +380,27 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
'orchestration check': async ({ flags, client, cwd, json }) => {
const wait = flags.has('wait')
const peek = flags.has('peek')
// Why: enforce mode exclusivity client-side too — an older runtime strips
// the unknown `peek` param and would otherwise execute --unread --peek as
// a destructive mark-read.
if ([flags.has('unread'), peek, flags.has('all')].filter(Boolean).length > 1) {
throw new RuntimeClientError(
'invalid_argument',
'Choose at most one message read mode: --unread, --peek, or --all.'
)
}
const timeoutMs = getOptionalPositiveIntegerValueFlag(flags, 'timeout-ms')
const terminal = await resolveOrchestrationTerminalHandle(flags, cwd, client, 'terminal')
// Why: Claude Code's Bash tool auto-backgrounds subprocesses that produce
// no output for ~2 min (shorter on the non-interactive path). Emit a
// heartbeat line to stderr every HEARTBEAT_INTERVAL_MS while the wait is
// keepalive line to stderr every KEEPALIVE_INTERVAL_MS while the wait is
// active so the parent process can see the subprocess is still alive.
// Stderr rather than stdout so stdout stays a single final JSON payload,
// and JSON-shaped rather than `# …` so `2>&1 | jq` pipelines still work
// (jq refuses `#`-prefixed lines). See design doc §3.4.
const stopHeartbeat = wait ? startCheckHeartbeat(timeoutMs) : null
const stopKeepalive = wait ? startCheckKeepalive(timeoutMs) : null
type CheckResult = {
messages: MessageSummary[]
count: number
@@ -392,7 +410,12 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
try {
result = await client.call<CheckResult>('orchestration.check', {
terminal,
unread: flags.has('unread') ? true : undefined,
// Why: --peek also sends unread:false so runtimes that predate the
// peek param (which their non-strict schema strips) degrade to the
// non-consuming all-messages mode instead of the destructive
// mark-read default; the read filter below restores peek semantics.
unread: flags.has('unread') ? true : peek ? false : undefined,
peek: peek ? true : undefined,
all: flags.has('all') ? true : undefined,
types: getOptionalStringFlag(flags, 'types'),
inject: flags.has('inject') ? true : undefined,
@@ -400,7 +423,41 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
timeoutMs
})
} finally {
stopHeartbeat?.()
stopKeepalive?.()
}
if (peek) {
const rawRowCount = result.result.messages.length
const unreadOnly = result.result.messages.filter((m) => m.read !== 1)
const removedReadRows = unreadOnly.length !== rawRowCount
// Why: read rows in a peek response are the pre-peek-runtime signature
// (its schema stripped `peek` and it ran the all mode). Such a runtime
// returned instead of blocking, so honoring --wait is impossible —
// failing beats silently returning empty before the deadline.
if (wait && removedReadRows && unreadOnly.length === 0) {
throw new RuntimeClientError(
'peek_wait_unsupported',
'The connected runtime does not support --peek with --wait; upgrade the runtime or use --wait without --peek.'
)
}
// Why: pre-peek runtimes cap the all mode at the newest 100 rows, so a
// full page means older unread messages may have been cut off. Warn on
// stderr so stdout stays a single JSON payload.
if (removedReadRows && rawRowCount >= 100) {
console.error(
'Warning: this runtime returned only its newest 100 messages for --peek; older unread messages may be missing. Upgrade the runtime for exact peek results.'
)
}
result = {
...result,
result: {
...result.result,
// Why: a pre-peek runtime builds `formatted` from all rows; drop it
// when the read filter removed any so output matches the peek set.
...(removedReadRows ? { formatted: undefined } : {}),
messages: unreadOnly,
count: unreadOnly.length
}
}
}
printResult(result, json, (r) => {
if (r.formatted) {
@@ -476,6 +533,7 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
},
'orchestration task-list': async ({ flags, client, json }) => {
const brief = flags.has('brief')
const result = await client.call<{
tasks: {
id: string
@@ -485,13 +543,26 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
status: string
assignee_handle?: string | null
dispatch_id?: string | null
spec_truncated?: boolean
}[]
count: number
}>('orchestration.taskList', {
status: getOptionalStringFlag(flags, 'status'),
ready: flags.has('ready') ? true : undefined
ready: flags.has('ready') ? true : undefined,
brief: brief ? true : undefined
})
printResult(result, json, (r) => {
// Why: current runtimes abbreviate server-side (rows carry
// spec_truncated) so full specs never cross the wire; older runtimes
// strip the brief param and need the client-side fallback.
const needsClientAbbreviation =
brief && result.result.tasks.some((task) => task.spec_truncated === undefined)
const output = needsClientAbbreviation
? {
...result,
result: { ...result.result, tasks: abbreviateOrchestrationTasks(result.result.tasks) }
}
: result
printResult(output, json, (r) => {
if (r.count === 0) {
return 'No tasks.'
}
+3
View File
@@ -499,6 +499,9 @@ describe('orca cli worktree awareness', () => {
delete process.env.ORCA_USER_DATA_PATH
delete process.env.ORCA_WORKSPACE_ID
delete process.env.ORCA_WORKTREE_ID
// Isolate the pane key so claude-teams tests that set it don't leak a
// senderPaneKey into later orchestration.send assertions.
delete process.env.ORCA_PANE_KEY
serveOrcaAppMock.mockReset()
getDefaultUserDataPathMock.mockClear()
addEnvironmentFromPairingCodeMock.mockReset()
+1
View File
@@ -116,6 +116,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
'--no-parent only affects Orca lineage; omit --base-branch to use the repo default base, or pass the default base ref explicitly for independent top-level work.',
'By default this creates the worktree and its first terminal without switching the active Orca view.',
'Pass --agent to launch an agent in the first terminal; --prompt sends initial work to that agent.',
'With --agent --json, read the new agent handle from result.agentTerminalHandle; older runtimes return only result.startupTerminal.handle, and may return neither for folder-based repos.',
'Repo-defined setup hooks follow the repository setup policy; pass --setup run to force them.',
'Pass --activate when the CLI caller intentionally wants to reveal the new worktree in the app.',
'Passing --run-hooks is kept as a legacy alias for --setup run and reveals the worktree.'
+11 -6
View File
@@ -26,6 +26,7 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
notes: [
'On Windows PowerShell, quote group addresses such as --to "@all" or --to "@worktree:<id>".',
'worker_done and heartbeat must target a concrete coordinator terminal handle; use status for broadcast updates.',
'A worker_done with the active task/dispatch IDs completes that task when sent from the dispatched pane (or when pane identity is unavailable); on older runtimes the sender must match the dispatch assignee handle, so avoid overriding --from.',
'Prefer --task-id/--dispatch-id/etc. over raw --payload JSON in worker commands; PowerShell strips JSON quotes easily.'
]
},
@@ -33,17 +34,20 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
path: ['orchestration', 'check'],
summary: 'Check messages for a terminal',
usage:
'orca orchestration check [--terminal <handle>] [--unread | --all] [--types <type,...>] [--inject] [--wait] [--timeout-ms <n>] [--json]\n' +
'orca orchestration check [--terminal <handle>] [--unread | --peek | --all] [--types <type,...>] [--inject] [--wait] [--timeout-ms <n>] [--json]\n' +
' --unread (default): return only unread messages and mark them read.\n' +
' --peek: return only unread messages without marking them read.\n' +
' --all: return every message for the handle; does not mark read.\n' +
' --wait: block until a matching message arrives or --timeout-ms expires.\n' +
' Emits JSON heartbeat lines to stderr every 15s so the caller can\n' +
' tell the process is alive. Filter with `grep -v _heartbeat` or\n' +
' `jq "select(._heartbeat|not)"` when merging streams with 2>&1.',
' Emits JSON keepalive lines to stderr every 15s so the caller can\n' +
' tell the process is alive. `_keepalive` is unrelated to heartbeat\n' +
' messages; `_heartbeat` remains as a deprecated compatibility alias.\n' +
' Filter with `jq "select(._keepalive|not)"` when merging streams.',
allowedFlags: [
...GLOBAL_FLAGS,
'terminal',
'unread',
'peek',
'all',
'types',
'inject',
@@ -76,8 +80,9 @@ export const ORCHESTRATION_COMMAND_SPECS: CommandSpec[] = [
{
path: ['orchestration', 'task-list'],
summary: 'List orchestration tasks',
usage: 'orca orchestration task-list [--status <status>] [--ready] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready']
usage: 'orca orchestration task-list [--status <status>] [--ready] [--brief] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'status', 'ready', 'brief'],
notes: ['--brief collapses whitespace and caps each spec at 160 characters.']
},
{
path: ['orchestration', 'task-update'],