diff --git a/src/main/runtime/rpc/orchestration-legacy-coordinator-authority.ts b/src/main/runtime/rpc/orchestration-legacy-coordinator-authority.ts index a6223ba073a..782460c891b 100644 --- a/src/main/runtime/rpc/orchestration-legacy-coordinator-authority.ts +++ b/src/main/runtime/rpc/orchestration-legacy-coordinator-authority.ts @@ -1,4 +1,5 @@ import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../orca-runtime' +import type { OrchestrationDb } from '../orchestration/db' import type { LegacyCompatibilityPrincipalRow } from '../orchestration/types' import type { LegacyCoordinatorAuthorityProof, RpcRequest } from './core' import { @@ -20,8 +21,12 @@ export class LegacyCoordinatorAuthority { ): LegacyCoordinatorAuthorityProof | undefined { const db = this.runtime.getOrchestrationDb() const adoption = db.getLegacyAdoption() - const requestedRun = requestedRunId ?? adoption?.adopted_run_id - if (!adoption || requestedRun !== adoption.adopted_run_id) { + if (!adoption) { + return undefined + } + // Why: an unnamed Run means the caller's own binding; only an unbound caller can still mean the adopted Run. + const requestedRun = requestedRunId ?? boundRunId(db, request) ?? adoption.adopted_run_id + if (requestedRun !== adoption.adopted_run_id) { return undefined } const candidate = db.resolveLegacyCoordinatorCandidate({ @@ -31,10 +36,17 @@ export class LegacyCoordinatorAuthority { }) if (!candidate) { if (request.orchestrationCompatibilityEvidence) { + const run = db.getRun(adoption.adopted_run_id) + // Why: an unclaimed adopted Run has no coordinator to fence — same rule as bindingMatches below. + if ( + !run?.coordinator_pane_key && + !db.getLegacyCoordinatorPrincipal(adoption.adopted_run_id) + ) { + return undefined + } const caller = this.runtime.verifyOrchestrationCompatibilityCaller( request.orchestrationCompatibilityEvidence ) - const run = db.getRun(adoption.adopted_run_id) if ( caller && run?.coordinator_handle === caller.terminalHandle && @@ -168,3 +180,8 @@ export class LegacyCoordinatorAuthority { ) } } + +function boundRunId(db: OrchestrationDb, request: RpcRequest): string | undefined { + const paneKey = request.orchestrationCompatibilityEvidence?.paneKey + return paneKey ? db.getCurrentRunForPane(paneKey)?.id : undefined +} diff --git a/src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts b/src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts new file mode 100644 index 00000000000..49e2b78d6ae --- /dev/null +++ b/src/main/runtime/rpc/orchestration-legacy-run-routing.test.ts @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + cleanupLegacyCompatibilityDispatcherHarnesses, + COORDINATOR_HANDLE, + createHarness, + currentEvidence, + CURRENT_COORDINATOR_HANDLE, + CURRENT_COORDINATOR_PANE, + evidence, + invoke, + request +} from './orchestration-legacy-compatibility-dispatcher-test-fixture' + +afterEach(() => { + cleanupLegacyCompatibilityDispatcherHarnesses() +}) + +describe('legacy coordinator gate run routing', () => { + it.each(['dispatch', 'websocket'] as const)( + '%s asks an unbound caller to bind a Run instead of fencing it as a legacy coordinator', + async (transport) => { + const harness = createHarness() + + const response = await invoke( + harness.dispatcher, + request( + 'orchestration.taskCreate', + { + spec: 'fresh assignment', + callerTerminalHandle: CURRENT_COORDINATOR_HANDLE + }, + currentEvidence('coordinator'), + `unbound-task-create-${transport}` + ), + transport + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'run_required' } + }) + expect(harness.db.listTasks({ runId: harness.adoptedRunId })).toHaveLength(1) + } + ) + + it('routes an unnamed Run to the caller binding even when attestation fails', async () => { + const harness = createHarness() + const run = harness.db.createRun({ + objective: 'current work', + coordinatorHandle: CURRENT_COORDINATOR_HANDLE, + coordinatorPaneKey: CURRENT_COORDINATOR_PANE + }) + + const response = await harness.dispatcher.dispatch( + request( + 'orchestration.taskCreate', + { + spec: 'fresh assignment', + callerTerminalHandle: CURRENT_COORDINATOR_HANDLE + }, + { ...currentEvidence('coordinator'), launchToken: '' }, + 'unattested-bound-task-create' + ) + ) + + expect(response).toMatchObject({ + ok: true, + result: { task: { run_id: run.id, spec: 'fresh assignment' } } + }) + }) + + it('lets a current coordinator rebind the unclaimed adopted Run with actionable guidance', async () => { + const harness = createHarness() + + const response = await harness.dispatcher.dispatch( + request( + 'orchestration.runUse', + { id: harness.adoptedRunId, from: CURRENT_COORDINATOR_HANDLE }, + currentEvidence('coordinator'), + 'unclaimed-adopted-run-use' + ) + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + data: { + recoveryCommand: `orca orchestration run-use --id ${harness.adoptedRunId} --takeover-legacy` + } + } + }) + }) + + it('still routes the retained legacy coordinator to the adopted Run without --run', async () => { + const harness = createHarness() + + const response = await harness.dispatcher.dispatch( + request( + 'orchestration.taskCreate', + { + spec: 'retained assignment', + callerTerminalHandle: COORDINATOR_HANDLE + }, + evidence('coordinator'), + 'retained-adopted-task-create' + ) + ) + + expect(response).toMatchObject({ + ok: true, + result: { + task: { run_id: harness.adoptedRunId, spec: 'retained assignment' } + } + }) + }) + + it('still fences a stale legacy coordinator once the adopted Run is claimed', async () => { + const harness = createHarness() + harness.db.bindRun({ + runId: harness.adoptedRunId, + coordinatorHandle: CURRENT_COORDINATOR_HANDLE, + coordinatorPaneKey: CURRENT_COORDINATOR_PANE, + takeoverLegacy: true + }) + + const response = await harness.dispatcher.dispatch( + request( + 'orchestration.taskCreate', + { + spec: 'stale assignment', + run: harness.adoptedRunId, + callerTerminalHandle: COORDINATOR_HANDLE + }, + evidence('coordinator'), + 'claimed-adopted-task-create' + ) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'legacy_read_only' } + }) + expect(harness.db.listTasks({ runId: harness.adoptedRunId })).toHaveLength(1) + }) +})