Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/cli/handlers/orchestration/dispatch-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record<string, CommandHandler> = {
const result = await callOrchestrationMutation<{
dispatch: { id: string; task_id: string; status: string } | null
injected?: boolean
/** The preamble landed but the agent's turn start was not observed; do not re-inject. */
promptUnobserved?: boolean
dryRun?: boolean
preamble?: string
}>(client, flags, 'orchestration.dispatch', {
Expand All @@ -34,7 +36,12 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record<string, CommandHandler> = {
return value.preamble ?? ''
}
const base = `Dispatched ${value.dispatch?.task_id} -> ${value.dispatch?.id} [${value.dispatch?.status}]`
return value.preamble ? `${base}\n\n--- Preamble ---\n${value.preamble}` : base
const unobserved = value.promptUnobserved
? `\nThe preamble was injected but the agent's turn start was not observed. It is in the composer; read the terminal before re-sending anything.`
Comment thread
coderabbitai[bot] marked this conversation as resolved.
: ''
return value.preamble
? `${base}${unobserved}\n\n--- Preamble ---\n${value.preamble}`
: `${base}${unobserved}`
})
}
}
Expand Down
25 changes: 20 additions & 5 deletions src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { OrchestrationError } from '../../orchestration/orchestration-error'
import { buildDispatchPreamble } from '../../orchestration/preamble'
import { resolveDispatchCreator } from './orchestration-dispatch-creator'
import { buildInjectRejectionMessage } from './orchestration-inject-rejection-message'
import { isAgentPromptStalledError } from '../../agent-prompt-submission-verification'
import { resolveRunScope } from './orchestration-run-scope'
import { DispatchParams, DispatchShowParams } from './orchestration-schemas'

Expand Down Expand Up @@ -123,21 +124,35 @@ export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [
})

let injected = false
let promptUnobserved = false
if (params.inject) {
try {
await runtime.sendTerminalAgentPrompt(to, preamble)
injected = true
} catch (err) {
db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err))
throw err
// Why (#17066): the preamble is in the pane before verification runs, so a stall is an
// unobserved turn start, never proof it is missing. Failing here would revoke the capability
// the worker's heartbeat and worker_done need while it is already executing the task.
if (isAgentPromptStalledError(err)) {
injected = true
promptUnobserved = true
console.warn(
`[orchestration] dispatch ${ctx.id}: preamble injected into ${to} but its turn start was not observed; dispatch stays active`
)
} else {
db.failDispatch(ctx.id, err instanceof Error ? err.message : String(err))
throw err
}
}
}

// Why: returnPreamble is opt-in because the preamble is several hundred bytes most callers don't need in the response.
if (params.returnPreamble) {
return { dispatch: ctx, injected, preamble }
return {
dispatch: ctx,
injected,
...(promptUnobserved ? { promptUnobserved } : {}),
...(params.returnPreamble ? { preamble } : {})
}
return { dispatch: ctx, injected }
}
}),

Expand Down
27 changes: 27 additions & 0 deletions src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,33 @@ describe('orchestration RPC methods', () => {
expect(db.getActiveDispatchForTerminal('term_a')).toBeUndefined()
})

it('keeps an injected dispatch active when the turn start is not observed', async () => {
setup()
provideInjectIdentity()
const task = db.createTask({ spec: 'work' })
vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true)
vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockRejectedValue(
new Error('agent_prompt_stalled')
)
vi.spyOn(console, 'warn').mockImplementation(() => {})

const result = (await call('orchestration.dispatch', {
task: task.id,
to: 'term_a',
inject: true
})) as {
dispatch: { id: string; status: string }
injected: boolean
promptUnobserved?: boolean
}

// Why: the preamble is in the composer; revoking here rejects the worker's own worker_done.
expect(result).toMatchObject({ injected: true, promptUnobserved: true })
expect(result.dispatch.status).toBe('dispatched')
expect(db.getTask(task.id)?.status).toBe('dispatched')
expect(db.getDispatchContextById(result.dispatch.id)?.capability_revoked_at).toBeNull()
})

it('uses caller-provided dev mode for injected preamble', async () => {
setup()
provideInjectIdentity()
Expand Down
Loading