fix(orchestration): decide a self-targeted --inject dispatch before agent detection

The no-recognized-agent rejection advised dispatching without --inject and
sending the prompt manually, which is exactly what the coordinator guard
forbids, so the same self-target input got opposite instructions depending on
whether an agent happened to be detected in the pane. The coordinator guard now
runs first. The flat dispatch-authority mock in manual-dispatch-observation gave
the coordinator handle the worker's pane, which authority never does per handle.
This commit is contained in:
Jinwoo-H
2026-09-04 15:23:45 -04:00
parent a16f5c0f2c
commit 8437ae2a99
3 changed files with 58 additions and 19 deletions
@@ -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,
@@ -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',
@@ -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', {