diff --git a/src/cli/handlers/orchestration/dispatch-handlers.ts b/src/cli/handlers/orchestration/dispatch-handlers.ts index afe79ab8e2f3..0530049b8298 100644 --- a/src/cli/handlers/orchestration/dispatch-handlers.ts +++ b/src/cli/handlers/orchestration/dispatch-handlers.ts @@ -17,6 +17,8 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 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', { @@ -34,7 +36,12 @@ export const ORCHESTRATION_DISPATCH_HANDLER: Record = { 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.` + : '' + return value.preamble + ? `${base}${unobserved}\n\n--- Preamble ---\n${value.preamble}` + : `${base}${unobserved}` }) } } diff --git a/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts index ae21a4e5a466..66b9e5f866ba 100644 --- a/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration-dispatch-methods.ts @@ -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' @@ -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 } } }), diff --git a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts index a216b4c7a4df..cd749d4f7c6a 100644 --- a/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-tasks-dispatch.test.ts @@ -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()