diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts index 8b9ea094a5f..f836a5fc02d 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -70,14 +70,6 @@ export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ throw new Error(`Task ${params.task} is ${task.status}; only ready tasks can be dispatched`) } - // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. - if (params.inject) { - const hasAgent = await runtime.isTerminalRunningAgent(to) - if (!hasAgent) { - throw new Error(buildInjectRejectionMessage(to)) - } - } - const dispatchAuthority = runtime.getOrchestrationDispatchAuthority(to) const assigneePaneKey = dispatchAuthority?.paneKey ?? runtime.getTerminalPaneKey(to) ?? undefined @@ -85,12 +77,6 @@ export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ dispatchAuthority?.paneKey && dispatchAuthority.processIncarnation ? dispatchAuthority.processIncarnation : undefined - if (params.inject && (!assigneePaneKey || !processIncarnation)) { - throw new OrchestrationError( - 'stable_pane_required', - `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` - ) - } // Why: the assignee side prefers dispatch authority, so the caller side must too — getTerminalPaneKey // alone returns null for a handle reachable only through the window-graph leaf, going inert here. const callerPane = params.from @@ -112,6 +98,21 @@ export const ORCHESTRATION_DISPATCH_METHODS: RpcMethod[] = [ ) } + // Why: injecting the preamble into a bare shell dumps it as shell commands (gibberish), so require a detected agent first. + if (params.inject) { + const hasAgent = await runtime.isTerminalRunningAgent(to) + if (!hasAgent) { + throw new Error(buildInjectRejectionMessage(to)) + } + } + + if (params.inject && (!assigneePaneKey || !processIncarnation)) { + throw new OrchestrationError( + 'stable_pane_required', + `Terminal ${to} has no stable pane/process incarnation for lifecycle authority.` + ) + } + revalidateLegacyCoordinator?.() const ctx = db.createDispatchContext({ taskId: params.task, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts index da02984e8b8..4cd8810ad6b 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/manual-dispatch-observation.test.ts @@ -18,11 +18,16 @@ describe('manual Dispatch observation', () => { vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => handle === 'term_coord' ? coordinatorPaneKey : workerPaneKey ) - vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockReturnValue({ - terminalHandle: 'term_worker', - paneKey: workerPaneKey, - processIncarnation: 'runtime_test:term_worker:1' - } as never) + // Authority is per handle in the real runtime; a flat mock would give the coordinator the worker's pane. + vi.spyOn(runtime, 'getOrchestrationDispatchAuthority').mockImplementation( + (handle) => + ({ + terminalHandle: handle, + paneKey: handle === 'term_coord' ? coordinatorPaneKey : workerPaneKey, + processIncarnation: + handle === 'term_coord' ? 'runtime_test:term_coord:1' : 'runtime_test:term_worker:1' + }) as never + ) vi.spyOn(runtime, 'isTerminalRunningAgent').mockResolvedValue(true) vi.spyOn(runtime, 'sendTerminalAgentPrompt').mockResolvedValue({ handle: 'term_worker', diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts index 382c9de8b0d..b2627bd13d3 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-start-terminal-target.test.ts @@ -114,6 +114,39 @@ describe('orchestration.dispatch --to the caller', () => { expect(harness.runtime.sendTerminalAgentPrompt).not.toHaveBeenCalled() }) + // The rejection for a missing agent tells the caller to dispatch without --inject, which the + // coordinator guard forbids; the self-target answer must not depend on agent presence. + it.each([ + ['the coordinator handle', 'term_coord'], + ['an alias of the coordinator pane', 'term_coord_alias'] + ])('refuses %s even when no agent is detected', async (_label, target) => { + vi.spyOn(harness.runtime, 'isTerminalRunningAgent').mockResolvedValue(false) + vi.spyOn(harness.runtime, 'getOrchestrationDispatchAuthority').mockImplementation( + (handle) => + (handle === 'term_coord_alias' + ? { + terminalHandle: handle, + paneKey: harness.coordinatorPaneKey, + processIncarnation: 'runtime_test:term_coord:1' + } + : null) as never + ) + const task = harness.db.createTask({ + spec: `self inject ${target}`, + runId: harness.activeRunId + }) + + await expect( + harness.call('orchestration.dispatch', { + task: task.id, + from: 'term_coord', + to: target, + inject: true + }) + ).rejects.toMatchObject({ code: 'terminal_is_coordinator' }) + expect(harness.db.getDispatchContext(task.id)).toBeUndefined() + }) + it('still dispatches to a different pane', async () => { const task = harness.db.createTask({ spec: 'peer dispatch', runId: harness.activeRunId }) const result = (await harness.call('orchestration.dispatch', {