mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user