From 88d52271502fd5b90c34448a91b99a681be7a2e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:03:36 -0700 Subject: [PATCH 01/15] refactor(orchestration): resolve a session caller at the dispatch entry and bind it by actor WIP: entry resolver on both dispatchers, caller identity through run scope, actor-keyed Run binding and unbind, actor writes on bind/create/assign, and actor-aware mail ownership exclusions. --- .../dispatch-context-store.ts | 2 + .../orchestration/db/dispatch-depth.ts | 51 +++- .../orchestration/db/dispatch-row-writer.ts | 17 +- .../db/messages/direct-mailbox-routing.ts | 8 +- .../foreign-direct-mailbox-routing.ts | 44 +++- .../orchestration/db/runs/run-binding.ts | 48 ++-- .../db/runs/run-coordinator-mail-routing.ts | 27 +- .../orchestration/db/runs/run-create.ts | 26 +- .../orchestration/db/runs/run-lookup.ts | 53 +++- .../worker-dispatch-authority.ts | 6 +- .../failed-start-dispatch-identity.ts | 6 +- .../worker-terminal-resource-store.ts | 14 ++ .../orchestration-caller-identity.ts | 59 +++++ src/main/runtime/rpc/core.ts | 3 + .../rpc/dispatcher-unary-method-invocation.ts | 3 +- src/main/runtime/rpc/dispatcher.ts | 22 +- .../methods/orchestration-caller-workspace.ts | 8 +- ...on-structured-worker-start-failure.test.ts | 2 +- .../federation/federated-worker-start.ts | 5 +- .../rpc/methods/orchestration/gates/gates.ts | 24 +- .../orchestration/messaging/check-methods.ts | 17 +- .../orchestration/messaging/check-run.ts | 5 + .../messaging/message-methods.ts | 44 +++- .../orchestration/messaging/send-group.ts | 5 +- .../orchestration/messaging/send-methods.ts | 13 +- .../rpc/methods/orchestration/routing.ts | 14 +- .../orchestration/runs/dispatch-creator.ts | 27 +- .../orchestration/runs/dispatch-methods.ts | 8 +- .../methods/orchestration/runs/run-scope.ts | 85 +++++-- .../rpc/methods/orchestration/runs/runs.ts | 35 +-- .../explicit-worker-terminal-validation.ts | 19 +- .../worker/local-worker-start.ts | 29 ++- .../methods/orchestration/worker/workers.ts | 15 +- .../rpc/orchestration-mutation-executor.ts | 6 +- .../rpc/orchestration-mutation-receipt.ts | 13 +- .../rpc/orchestration-session-caller.ts | 230 ++++++++++++++++++ .../runtime/rpc/rpc-streaming-dispatcher.ts | 44 +++- .../runtime/structured-worker-authority.ts | 16 ++ .../runtime/structured-worker-identity.ts | 13 + .../orchestration-compatibility-evidence.ts | 5 + .../orchestration-session-caller-codes.ts | 14 ++ 41 files changed, 904 insertions(+), 181 deletions(-) create mode 100644 src/main/runtime/orchestration/orchestration-caller-identity.ts create mode 100644 src/main/runtime/rpc/orchestration-session-caller.ts create mode 100644 src/shared/orchestration-session-caller-codes.ts diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index 38905f30434..815bfd9d2ba 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -9,6 +9,7 @@ import { recordedCreatorIdentity, type DispatchCreator } from '../dispatch-depth import type { OrchestrationDb } from '../orchestration-db' import { transitionLifecycleWithDb } from '../lifecycle-transition' import { taskNotFoundError, taskNotStartableError } from '../../task-dispatch-refusal' +import { structuredWorkerActorForIncarnation } from '../../../structured-worker-identity' export function createDispatchContext( this: OrchestrationDb, @@ -65,6 +66,7 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, + assigneeActor: structuredWorkerActorForIncarnation(processIncarnation), processIncarnation: processIncarnation ?? null, creatorDispatchId, ...recordedCreatorIdentity(params.creator), diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index cc4ba026db9..2ba59b53e67 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -26,17 +26,29 @@ export type DispatchCreator = paneKey?: string /** Remote attachment matching requires the exact incarnation; local rows do not. */ processIncarnation?: string + /** A structured worker's `session:`, recorded beside its handle. */ + actor?: string | null } + /** A structured session with no terminal handle, identified by its actor alone. */ + | { kind: 'actor'; actor: string } /** Creator identity to persist on a new row, so depth can later tell delegation from bookkeeping. */ export function recordedCreatorIdentity(creator: DispatchCreator): { creatorHandle: string | null creatorPaneKey: string | null + creatorActor: string | null } { if (creator.kind === 'system') { - return { creatorHandle: null, creatorPaneKey: null } + return { creatorHandle: null, creatorPaneKey: null, creatorActor: null } + } + if (creator.kind === 'actor') { + return { creatorHandle: null, creatorPaneKey: null, creatorActor: creator.actor } + } + return { + creatorHandle: creator.handle, + creatorPaneKey: creator.paneKey ?? null, + creatorActor: creator.actor ?? null } - return { creatorHandle: creator.handle, creatorPaneKey: creator.paneKey ?? null } } /** @@ -45,6 +57,9 @@ export function recordedCreatorIdentity(creator: DispatchCreator): { * no creator and keep counting, which is the pre-v37 answer and fails closed. */ function isSelfCreatedDispatch(row: DispatchContextRow): boolean { + if (row.creator_actor && row.creator_actor === row.assignee_actor) { + return true + } if (row.creator_pane_key && row.assignee_pane_key) { return isEquivalentPaneKey(row.creator_pane_key, row.assignee_pane_key) } @@ -84,14 +99,14 @@ export function resolveCreatorDepth(this: OrchestrationDb, creator: DispatchCrea // Local rows match on handle/pane as they always have. process_incarnation is // nullable here and context-only dispatch stores null deliberately, so // requiring it would drop real parents. - const local = this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) as - | DispatchContextRow - | undefined + const local = findActiveDispatchForCreator.call(this, creator) if (local && !isSelfCreatedDispatch(local)) { depths.push(local.depth) } - for (const attachment of findPotentiallyLiveAttachmentsForCreator.call(this, creator)) { + const attachments = + creator.kind === 'terminal' ? findPotentiallyLiveAttachmentsForCreator.call(this, creator) : [] + for (const attachment of attachments) { depths.push(attachment.depth) } @@ -109,16 +124,36 @@ export function resolveCreatorDispatchId( if (creator.kind === 'system') { return null } - const own = this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) + const own = findActiveDispatchForCreator.call(this, creator) // Why: a self-dispatch is not a parent Attempt, so it must not be stamped as the child's creator. const local = own && !isSelfCreatedDispatch(own) ? own : undefined - const remote = findPotentiallyLiveAttachmentsForCreator.call(this, creator) + const remote = + creator.kind === 'terminal' ? findPotentiallyLiveAttachmentsForCreator.call(this, creator) : [] if ((local ? 1 : 0) + remote.length !== 1) { return null } return local?.id ?? remote[0]?.dispatch_id ?? null } +/** The live Dispatch this creator is itself working on, found the way its identity is recorded. */ +function findActiveDispatchForCreator( + this: OrchestrationDb, + creator: Exclude +): DispatchContextRow | undefined { + if (creator.kind === 'actor') { + return this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE assignee_actor = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .get(creator.actor) as DispatchContextRow | undefined + } + return this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) as + | DispatchContextRow + | undefined +} + /** * Remote attachments matching this caller's pane AND exact process incarnation. * diff --git a/src/main/runtime/orchestration/db/dispatch-row-writer.ts b/src/main/runtime/orchestration/db/dispatch-row-writer.ts index 84862ee343d..a1f04800fac 100644 --- a/src/main/runtime/orchestration/db/dispatch-row-writer.ts +++ b/src/main/runtime/orchestration/db/dispatch-row-writer.ts @@ -14,11 +14,11 @@ import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' export const DISPATCH_CONTEXT_CLAIM_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, - assignee_handle, assignee_pane_key, process_incarnation, - creator_dispatch_id, creator_handle, creator_pane_key, + assignee_handle, assignee_pane_key, assignee_actor, process_incarnation, + creator_dispatch_id, creator_handle, creator_pane_key, creator_actor, status, failure_count, depth, dispatched_at ) -SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') +SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') FROM tasks WHERE id = ? AND status = 'ready' AND NOT EXISTS ( @@ -45,8 +45,9 @@ WHERE id = ? AND status = 'ready' const STARTING_DISPATCH_CONTEXT_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, retry_of_dispatch_id, - creator_dispatch_id, creator_handle, creator_pane_key, depth, status, dispatched_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` + creator_dispatch_id, creator_handle, creator_pane_key, creator_actor, depth, status, + dispatched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` const REMOTE_DISPATCH_ATTACHMENT_SQL = `INSERT INTO remote_dispatch_attachments ( dispatch_id, home_run_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch, depth @@ -70,10 +71,12 @@ export function claimDispatchContextRow( launchTokenHash: string | null assigneeHandle: string assigneePaneKey: string | null + assigneeActor?: string | null processIncarnation: string | null creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorActor?: string | null priorFailures: number depth: number taskId: string @@ -89,10 +92,12 @@ export function claimDispatchContextRow( params.launchTokenHash, params.assigneeHandle, params.assigneePaneKey, + params.assigneeActor ?? null, params.processIncarnation, params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorActor ?? null, params.priorFailures, params.depth, params.taskId, @@ -118,6 +123,7 @@ export function insertStartingDispatchContextRow( creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorActor?: string | null } ): void { assertStampedDepth(params.depth) @@ -131,6 +137,7 @@ export function insertStartingDispatchContextRow( params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorActor ?? null, params.depth ) } diff --git a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts index aa7d93fbea2..c1e5ee99562 100644 --- a/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/direct-mailbox-routing.ts @@ -1,6 +1,7 @@ import type { MessageType } from '../../types' import type { OrchestrationDb } from '../orchestration-db' import { ORCHESTRATION_DELIVERY_BATCH_LIMIT, type MailboxRoutingPage } from './mailbox-routing-page' +import { activeDispatchOwnsAddressSql } from '../runs/run-coordinator-mail-routing' export function hasUndeliveredDirectMessageForRun( this: OrchestrationDb, @@ -48,12 +49,7 @@ export function routeDirectMessagePage( try { const throughClause = throughSequence === undefined ? '' : ' AND sequence <= ?' const dispatchOwnershipClause = preserveActiveDispatchOwnership - ? ` AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE dispatch_contexts.run_id = messages.run_id - AND dispatch_contexts.assignee_handle = messages.to_handle - AND dispatch_contexts.status IN ('pending', 'dispatched') - )` + ? ` AND NOT ${activeDispatchOwnsAddressSql('messages.run_id', 'messages.to_handle')}` : '' const params: (string | number)[] = [runId, directHandle] if (throughSequence !== undefined) { diff --git a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts index 1b5afd1fbcb..2a6fd887729 100644 --- a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts @@ -1,4 +1,5 @@ import { parsePaneKey } from '../../../../../shared/stable-pane-id' +import { parseOrchestrationActor } from '../../../../../shared/orchestration-actor' import type { DispatchContextRow, MessageType } from '../../types' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' @@ -20,9 +21,25 @@ export function findActiveDispatchForDirectMessageOwner( ORDER BY rowid DESC LIMIT 1` ) .get(runId, directHandle) as DispatchContextRow | undefined - if (exact || !paneKey || !parsePaneKey(paneKey)) { + if (exact) { return exact } + // A session address owns the Dispatch its session is assigned, as a handle owns its own. + if (parseOrchestrationActor(directHandle)) { + const byActor = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE run_id = ? AND assignee_actor = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .get(runId, directHandle) as DispatchContextRow | undefined + if (byActor) { + return byActor + } + } + if (!paneKey || !parsePaneKey(paneKey)) { + return undefined + } return this.db .prepare( `SELECT * FROM dispatch_contexts @@ -76,6 +93,31 @@ export function routeForeignDirectMessagesToOwnedMailboxes( [directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1], [directHandle, directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1] ] + if (parseOrchestrationActor(directHandle)) { + branches.push( + `SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence + FROM ( + SELECT actor_dispatch.run_id + FROM dispatch_contexts AS actor_dispatch INDEXED BY idx_dispatch_assignee_actor + JOIN runs AS owner_run + ON owner_run.id = actor_dispatch.run_id AND owner_run.legacy = 0 + WHERE actor_dispatch.assignee_actor = ? + AND actor_dispatch.status IN ('pending', 'dispatched') + GROUP BY actor_dispatch.run_id + ) AS actor_owner + JOIN messages AS candidate INDEXED BY idx_messages_undelivered_direct_run + ON candidate.run_id = actor_owner.run_id AND candidate.to_handle = ? + WHERE 1 = 1${runExclusion} + AND candidate.read = 0 AND candidate.delivered_at IS NULL + AND candidate.delivery_contract = 'current_delivery'` + ) + branchParams.push([ + directHandle, + directHandle, + ...exclusionParams, + ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1 + ]) + } if (paneSuffix !== undefined) { branches.push( `SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index c5e6f34abfa..eb7344fddbc 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -3,13 +3,16 @@ import { OrchestrationError } from '../../orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../contract-constants' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import { runBoundToCoordinator } from '../../orchestration-caller-identity' export function bindRun( this: OrchestrationDb, params: { runId: string - coordinatorHandle: string - coordinatorPaneKey: string + coordinatorHandle: string | null + coordinatorPaneKey: string | null + /** `session:` when the coordinator is a structured session; see orchestration-actor. */ + coordinatorActor?: string | null takeoverLegacy?: boolean legacyCoordinatorAuthority?: { runId: string @@ -20,6 +23,11 @@ export function bindRun( } } ): RunRow | undefined { + const coordinator = { + terminalHandle: params.coordinatorHandle, + paneKey: params.coordinatorPaneKey, + actor: params.coordinatorActor ?? null + } this.db.exec('BEGIN IMMEDIATE') try { const run = this.getRunRaw(params.runId) @@ -27,9 +35,7 @@ export function bindRun( this.db.exec('ROLLBACK') return undefined } - const sameBinding = - run.coordinator_pane_key !== null && - isEquivalentPaneKey(run.coordinator_pane_key, params.coordinatorPaneKey) + const sameBinding = runBoundToCoordinator(run, coordinator) const adoption = this.getLegacyAdoption() const adoptedRun = adoption?.adopted_run_id === params.runId const legacyAuthority = params.legacyCoordinatorAuthority @@ -49,6 +55,7 @@ export function bindRun( legacyPrincipal.terminal_handle === legacyAuthority.terminalHandle && isEquivalentPaneKey(legacyPrincipal.pane_key, legacyAuthority.paneKey) && params.coordinatorHandle === legacyAuthority.terminalHandle && + params.coordinatorPaneKey !== null && isEquivalentPaneKey(params.coordinatorPaneKey, legacyAuthority.paneKey) ) if (legacyAuthority && !provenLegacyBinding) { @@ -109,14 +116,18 @@ export function bindRun( } ) } - this.unbindOtherRunsForPane(params.coordinatorPaneKey, params.runId) - for (const handle of new Set( - [run.coordinator_handle, params.coordinatorHandle].filter((value): value is string => - Boolean(value) - ) + this.unbindOtherRunsForCoordinator(coordinator, params.runId) + // Both coordinators' addresses, and a structured worker's session address beside its handle. + for (const address of new Set( + [ + run.coordinator_handle, + run.coordinator_actor, + coordinator.terminalHandle, + coordinator.actor + ].filter((value): value is string => Boolean(value)) )) { - this.rememberRunCoordinatorHandle(params.runId, handle) - this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, handle) + this.rememberRunCoordinatorHandle(params.runId, address) + this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, address) } if ( (params.takeoverLegacy && !takeoverAlreadyApplied) || @@ -128,26 +139,31 @@ export function bindRun( coordinatorPrincipal?.status === 'committed' && (params.takeoverLegacy || coordinatorPrincipal.terminal_handle !== params.coordinatorHandle || + params.coordinatorPaneKey === null || !isEquivalentPaneKey(coordinatorPrincipal.pane_key, params.coordinatorPaneKey)) ) { this.setLegacyCompatibilityPrincipalStatus(coordinatorPrincipal.id, 'revoked') } } - // The Orca session id belongs to the coordinator being replaced; nothing here resolves the new one's. this.db .prepare( `UPDATE runs - SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_orca_session_id = NULL, - coordinator_orca_session_id_generation = NULL, + SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation + 1, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` ) - .run(params.coordinatorHandle, params.coordinatorPaneKey, params.runId) + .run(coordinator.terminalHandle, coordinator.paneKey, coordinator.actor, params.runId) this.fenceUnacknowledgedMailboxDeliveries(`run:${params.runId}`) if (params.takeoverLegacy || replacesLegacyCoordinator) { this.promoteLegacyCoordinatorMailForTakeover(params.runId, retainedCoordinatorHandle) } + } else if (run.coordinator_actor !== coordinator.actor) { + // Same coordinator, so no new consumer: correct an actor a writer without the column left. + this.db + .prepare('UPDATE runs SET coordinator_actor = ? WHERE id = ?') + .run(coordinator.actor, params.runId) } this.db.exec('COMMIT') } catch (error) { diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts index 07a6044d5aa..6861d182554 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts @@ -1,6 +1,20 @@ import type { OrchestrationDb } from '../orchestration-db' import { currentRunCoordinatorSessionAddressSql } from './run-coordinator-orca-session' +/** + * Mail to an address that is also an active Dispatch assignee in the same Run is that worker's + * mail, not coordinator mail, whether the address is a terminal handle or a session actor. + */ +export function activeDispatchOwnsAddressSql(runIdSql: string, addressSql: string): string { + return `EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE dispatch_contexts.run_id = ${runIdSql} + AND (dispatch_contexts.assignee_handle = ${addressSql} + OR dispatch_contexts.assignee_actor = ${addressSql}) + AND dispatch_contexts.status IN ('pending', 'dispatched') + )` +} + export function rememberRunCoordinatorHandle( this: OrchestrationDb, runId: string, @@ -43,11 +57,7 @@ export function createCoordinatorMailRoutingTrigger(this: OrchestrationDb): void SELECT 1 FROM run_coordinator_handles WHERE run_id = NEW.run_id AND terminal_handle = NEW.to_handle ) - AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE run_id = NEW.run_id AND assignee_handle = NEW.to_handle - AND status IN ('pending', 'dispatched') - ) + AND NOT ${activeDispatchOwnsAddressSql('NEW.run_id', 'NEW.to_handle')} BEGIN UPDATE messages SET to_handle = 'run:' || NEW.run_id WHERE sequence = NEW.sequence; END; @@ -69,12 +79,7 @@ export function routeAllUnreadDirectMessagesToRunMailbox( `UPDATE messages SET to_handle = ? WHERE run_id = ? AND to_handle = ? AND read = 0 AND delivery_contract = 'current_delivery' - AND NOT EXISTS ( - SELECT 1 FROM dispatch_contexts - WHERE dispatch_contexts.run_id = messages.run_id - AND dispatch_contexts.assignee_handle = messages.to_handle - AND dispatch_contexts.status IN ('pending', 'dispatched') - )` + AND NOT ${activeDispatchOwnsAddressSql('messages.run_id', 'messages.to_handle')}` ) .run(`run:${runId}`, runId, directHandle) } diff --git a/src/main/runtime/orchestration/db/runs/run-create.ts b/src/main/runtime/orchestration/db/runs/run-create.ts index 3f99e018936..a41a898ee76 100644 --- a/src/main/runtime/orchestration/db/runs/run-create.ts +++ b/src/main/runtime/orchestration/db/runs/run-create.ts @@ -8,23 +8,35 @@ export function createRun( this: OrchestrationDb, params: { objective: string - coordinatorHandle: string - coordinatorPaneKey: string + coordinatorHandle: string | null + coordinatorPaneKey: string | null + /** `session:` when the coordinator is a structured session; see orchestration-actor. */ + coordinatorActor?: string | null } ): RunRow { + const coordinator = { + terminalHandle: params.coordinatorHandle, + paneKey: params.coordinatorPaneKey, + actor: params.coordinatorActor ?? null + } const id = generateId('run') this.db.exec('BEGIN IMMEDIATE') try { - this.unbindOtherRunsForPane(params.coordinatorPaneKey) + this.unbindOtherRunsForCoordinator(coordinator) this.db .prepare( `INSERT INTO runs ( - id, objective, coordinator_handle, coordinator_pane_key, + id, objective, coordinator_handle, coordinator_pane_key, coordinator_actor, consumer_generation, legacy - ) VALUES (?, ?, ?, ?, 1, 0)` + ) VALUES (?, ?, ?, ?, ?, 1, 0)` ) - .run(id, params.objective, params.coordinatorHandle, params.coordinatorPaneKey) - this.rememberRunCoordinatorHandle(id, params.coordinatorHandle) + .run(id, params.objective, coordinator.terminalHandle, coordinator.paneKey, coordinator.actor) + // A structured worker is addressed by its handle and its session, so both reach this Run. + for (const address of new Set([coordinator.terminalHandle, coordinator.actor])) { + if (address) { + this.rememberRunCoordinatorHandle(id, address) + } + } this.db.exec('COMMIT') } catch (error) { this.db.exec('ROLLBACK') diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index 0a60b4e7f9d..b07ed1c0f3b 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -1,4 +1,8 @@ import type { RunRow } from '../../types' +import { + runBoundToCoordinator, + type OrchestrationCoordinatorKey +} from '../../orchestration-caller-identity' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../../shared/orchestration-run-pagination' import { isEquivalentPaneKey, @@ -22,6 +26,13 @@ const RUNS_BOUND_TO_PANE_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0 AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ? ORDER BY rowid` +// Why: one statement so pane and actor matches keep a single rowid order; the JS predicate decides. +const RUNS_BOUND_TO_COORDINATOR_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs + WHERE legacy = 0 AND ( + (coordinator_pane_key IS NOT NULL AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ?) + OR coordinator_actor = ? + ) + ORDER BY rowid` export function getRun(this: OrchestrationDb, id: string): RunRow | undefined { const run = this.getRunRaw(id) @@ -122,15 +133,39 @@ export function getRunRaw(this: OrchestrationDb, id: string): RunRow | undefined return this.db.prepare(RUN_BY_ID_SQL).get(id) as RunRow | undefined } -export function unbindOtherRunsForPane( +export function getCurrentRunForCoordinator( + this: OrchestrationDb, + caller: OrchestrationCoordinatorKey +): RunRow | undefined { + const run = this.runsBoundToCoordinator(caller)[0] + return run ? exposeRunTimestamps(run) : undefined +} + +/** Runs bound to this caller by pane or by actor; a caller without an actor matches as before. */ +export function runsBoundToCoordinator( + this: OrchestrationDb, + caller: OrchestrationCoordinatorKey +): RunRow[] { + if (caller.actor === null) { + return caller.paneKey === null ? [] : this.runsBoundToPane(caller.paneKey) + } + const suffix = caller.paneKey === null ? null : paneKeyMatchSuffix(caller.paneKey) + return ( + this.db.prepare(RUNS_BOUND_TO_COORDINATOR_SQL).all(suffix, caller.actor) as RunRow[] + ).filter((run) => runBoundToCoordinator(run, caller)) +} + +export function unbindOtherRunsForCoordinator( this: OrchestrationDb, - paneKey: string, + caller: OrchestrationCoordinatorKey, exceptRunId?: string ): void { - for (const run of this.runsBoundToPane(paneKey)) { + for (const run of this.runsBoundToCoordinator(caller)) { if (run.id !== exceptRunId) { - if (run.coordinator_handle) { - this.routeAllUnreadDirectMessagesToRunMailbox(run.id, run.coordinator_handle) + for (const address of new Set([run.coordinator_handle, run.coordinator_actor])) { + if (address) { + this.routeAllUnreadDirectMessagesToRunMailbox(run.id, address) + } } this.db .prepare( @@ -160,8 +195,10 @@ export type RunLookupMethods = { listRuns: typeof listRuns getCurrentRunForPane: typeof getCurrentRunForPane runsBoundToPane: typeof runsBoundToPane + getCurrentRunForCoordinator: typeof getCurrentRunForCoordinator + runsBoundToCoordinator: typeof runsBoundToCoordinator getRunRaw: typeof getRunRaw - unbindOtherRunsForPane: typeof unbindOtherRunsForPane + unbindOtherRunsForCoordinator: typeof unbindOtherRunsForCoordinator requireRun: typeof requireRun } @@ -173,8 +210,10 @@ export function attachRunLookup(ctor: { prototype: object }): void { listRuns, getCurrentRunForPane, runsBoundToPane, + getCurrentRunForCoordinator, + runsBoundToCoordinator, getRunRaw, - unbindOtherRunsForPane, + unbindOtherRunsForCoordinator, requireRun }) } diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index 166ccd7193e..d052b0a9a1c 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -2,6 +2,7 @@ import { randomBytes } from 'node:crypto' import { OrchestrationError } from '../../orchestration-error' import { hashDispatchCapability } from '../dispatch-capability-hash' import type { OrchestrationDb } from '../orchestration-db' +import { structuredWorkerActorForIncarnation } from '../../../structured-worker-identity' export function prepareStartingWorkerAuthority( this: OrchestrationDb, @@ -53,8 +54,8 @@ export function prepareStartingWorkerAuthority( const contextUpdate = this.db .prepare( `UPDATE dispatch_contexts - SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, - assignee_orca_session_id = NULL, host_scope = ?, + SET assignee_handle = ?, assignee_pane_key = ?, assignee_orca_session_id = ?, + process_incarnation = ?, host_scope = ?, capability_hash = ?, launch_token_hash = COALESCE(launch_token_hash, ?), capability_revoked_at = NULL, consumer_generation = consumer_generation + 1 @@ -63,6 +64,7 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, + structuredWorkerActorForIncarnation(params.processIncarnation), params.processIncarnation, params.hostScope ?? null, hashDispatchCapability(capability), diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index c9b9b5d0afd..4fd9df16eb2 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -1,5 +1,6 @@ import type { WorkerDispatchRow } from '../../types' import type { OrchestrationDb } from '../orchestration-db' +import { structuredWorkerActorForIncarnation } from '../../../structured-worker-identity' /** * A start that dies before `prepareStartingWorkerAuthority` never filled the Dispatch context in, @@ -21,13 +22,14 @@ export function recordFailedStartDispatchIdentity( db.db .prepare( `UPDATE dispatch_contexts - SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, host_scope = ?, - assignee_orca_session_id = NULL + SET assignee_handle = ?, assignee_pane_key = ?, assignee_orca_session_id = ?, + process_incarnation = ?, host_scope = ? WHERE id = ? AND status = 'failed' AND capability_hash IS NULL` ) .run( resource.terminal_handle, resource.pane_key, + structuredWorkerActorForIncarnation(resource.process_incarnation), resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index e6942503dbf..26e5b2977c2 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -123,6 +123,18 @@ export function getWorkerTerminalResourceByHandle( .get(terminalHandle) as WorkerTerminalResourceRow | undefined } +export function getWorkerTerminalResourceByProcessIncarnation( + this: OrchestrationDb, + processIncarnation: string +): WorkerTerminalResourceRow | undefined { + return this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE process_incarnation = ? ORDER BY updated_at DESC LIMIT 1` + ) + .get(processIncarnation) as WorkerTerminalResourceRow | undefined +} + export function getWorkerTerminalResourceFormerlyOwnedBy( this: OrchestrationDb, dispatchId: string @@ -238,6 +250,7 @@ export type WorkerTerminalResourceStoreMethods = { createWorkerTerminalResourceStatement: typeof createWorkerTerminalResourceStatement getWorkerTerminalResource: typeof getWorkerTerminalResource getWorkerTerminalResourceByHandle: typeof getWorkerTerminalResourceByHandle + getWorkerTerminalResourceByProcessIncarnation: typeof getWorkerTerminalResourceByProcessIncarnation getWorkerTerminalResourceByOwner: typeof getWorkerTerminalResourceByOwner getWorkerTerminalResourceFormerlyOwnedBy: typeof getWorkerTerminalResourceFormerlyOwnedBy recordWorkerTerminalRecoveryAttempt: typeof recordWorkerTerminalRecoveryAttempt @@ -251,6 +264,7 @@ export function attachWorkerTerminalResourceStore(ctor: { prototype: object }): createWorkerTerminalResourceStatement, getWorkerTerminalResource, getWorkerTerminalResourceByHandle, + getWorkerTerminalResourceByProcessIncarnation, getWorkerTerminalResourceByOwner, getWorkerTerminalResourceFormerlyOwnedBy, recordWorkerTerminalRecoveryAttempt, diff --git a/src/main/runtime/orchestration/orchestration-caller-identity.ts b/src/main/runtime/orchestration/orchestration-caller-identity.ts new file mode 100644 index 00000000000..89ef5fe3a5e --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-caller-identity.ts @@ -0,0 +1,59 @@ +import type { RunRow } from './types' +import { isEquivalentPaneKey } from './db/pane-key-match' + +/** + * Who an orchestration caller is, as Run binding and mail routing match it. + * + * A PTY agent is its terminal: a handle and a pane key, no actor. An agent that is a structured + * session is its actor (`session:`); a structured worker also has the handle and pane key it + * was minted, and an ordinary chat has neither. Methods pass this through whole and never branch + * on which fields are set; the lookups below own that. + */ +export type OrchestrationCallerIdentity = Readonly<{ + /** Mailbox address the caller sends from and reads: its terminal handle, else its actor. */ + address: string + terminalHandle: string | null + paneKey: string | null + actor: string | null +}> + +/** The part of a caller a Run binding stores and matches. */ +export type OrchestrationCoordinatorKey = Pick< + OrchestrationCallerIdentity, + 'terminalHandle' | 'paneKey' | 'actor' +> + +/** A caller the dispatch entry resolved from the Orca session id in its injected environment. */ +export type OrchestrationSessionCaller = OrchestrationCallerIdentity & + Readonly<{ + actor: string + sessionId: string + /** Where the session runs, from its record; `worker-start --worktree current` places here. */ + workspaceId: string + }> + +/** A caller with neither a pane nor an actor can never be bound to a Run. */ +export function hasRunBindingKey(caller: OrchestrationCoordinatorKey): boolean { + return caller.paneKey !== null || caller.actor !== null +} + +/** + * An actor binding counts only while the row's handle is the one that actor binds with: none for a + * handle-less session, its own handle for a structured worker. Every binary that predates the actor + * column rewrites `coordinator_handle` whenever it rebinds or unbinds a Run, so an actor it left + * behind can never satisfy this and is ignored without a cleanup pass. + */ +export function runBoundToCoordinator(run: RunRow, caller: OrchestrationCoordinatorKey): boolean { + if ( + caller.paneKey !== null && + run.coordinator_pane_key !== null && + isEquivalentPaneKey(run.coordinator_pane_key, caller.paneKey) + ) { + return true + } + return ( + caller.actor !== null && + run.coordinator_actor === caller.actor && + run.coordinator_handle === caller.terminalHandle + ) +} diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index c59c8da64d3..65bb3e1e2db 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -10,6 +10,7 @@ import type { } from '../../../shared/mobile-relay-credential-contract' import type { RuntimeCapability } from '../../../shared/protocol-version' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' export type PairingRpcContext = { getEndpoints(params: PairingGetEndpointsParams): Promise @@ -98,6 +99,8 @@ export type RpcContext = { replayedMutationReceipt?: unknown // Why: Run-scoped handlers must compare declared handles with request attestation. orchestrationCompatibilityEvidence?: OrchestrationCompatibilityEvidence + // Why: resolved once at the dispatch entry from the caller's Orca session id; the session wins. + orchestrationCaller?: OrchestrationSessionCaller // Why: only the compatibility authority router can set this trusted scope; user params cannot bypass Run consumer binding. legacyCoordinatorRunId?: string legacyCoordinatorAuthority?: LegacyCoordinatorAuthorityProof diff --git a/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts b/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts index 60d9728f152..abc38647683 100644 --- a/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts +++ b/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts @@ -82,7 +82,8 @@ export async function invokeDispatcherUnaryMethod({ request, effectiveParams, invoke, - legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint + legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint, + context.orchestrationCaller?.actor ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) return result diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index 2c407207197..fd28620892e 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -23,6 +23,10 @@ import { mapDispatcherError } from './dispatcher-error-response' import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' +import { + resolveOrchestrationSessionCaller, + type ResolvedOrchestrationRequest +} from './orchestration-session-caller' export type DispatcherOptions = { runtime: OrcaRuntimeService @@ -73,6 +77,17 @@ export class RpcDispatcher { if (parsedParams.error) { return parsedParams.error } + let resolved: ResolvedOrchestrationRequest + try { + resolved = await resolveOrchestrationSessionCaller( + this.runtime, + request, + parsedParams.value, + options + ) + } catch (error) { + return mapDispatcherError(request, meta, error) + } if (isStreamingMethod(method)) { return errorResponse( @@ -89,9 +104,9 @@ export class RpcDispatcher { try { const result = await invokeDispatcherUnaryMethod({ runtime: this.runtime, - request, + request: resolved.request, method, - params: parsedParams.value, + params: resolved.params, context: { runtime: this.runtime, signal: options?.signal, @@ -102,7 +117,8 @@ export class RpcDispatcher { clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, orchestrationCapability: request.orchestrationCapability, - authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint + authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint, + orchestrationCaller: resolved.caller }, orchestrationMutations: this.orchestrationMutations, legacyOrchestration: this.legacyOrchestration diff --git a/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts index 556938eef23..1c94a195651 100644 --- a/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts +++ b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts @@ -14,12 +14,18 @@ */ import type { OrcaRuntimeService } from '../../orca-runtime' +import type { OrchestrationSessionCaller } from '../../orchestration/orchestration-caller-identity' import { isStructuredWorkerHandle } from '../../structured-worker-identity' export async function resolveDispatchCallerWorktreeId( runtime: Pick, - callerHandle: string + callerHandle: string, + callerSession?: OrchestrationSessionCaller ): Promise { + // A session caller's workspace is on its record, whichever owner (chat or terminal) holds it. + if (callerSession) { + return callerSession.workspaceId + } if (isStructuredWorkerHandle(callerHandle)) { const worktreeId = runtime.getOrchestrationDispatchAuthority?.(callerHandle)?.worktreeId ?? null if (worktreeId) { diff --git a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts index 1f29dfb4904..98a843fb695 100644 --- a/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts +++ b/src/main/runtime/rpc/methods/orchestration-structured-worker-start-failure.test.ts @@ -136,7 +136,7 @@ describe('a structured worker-start that fails after the session exists', () => db, run: { id: 'run_1' } as never, existingTask: { id: 't1', spec: 'do the thing' } as never, - coordinatorPane: null, + coordinator: null, orchestrationMutation: undefined }) diff --git a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts index a7c59b7064b..957b4a6dde7 100644 --- a/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/federation/federated-worker-start.ts @@ -1,3 +1,4 @@ +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import { isTuiAgent } from '../../../../../../shared/tui-agent-config' import type { RuntimeStatus } from '../../../../../../shared/runtime-types' import { @@ -45,6 +46,8 @@ export async function startFederatedWorker(args: { method: string payloadHash: string } + /** The coordinator's resolved session, when it is one; recorded as the Dispatch creator. */ + callerSession?: OrchestrationSessionCaller }): Promise { const { params, runtime, db, task, runId, orchestrationMutation } = args if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { @@ -117,7 +120,7 @@ export async function startFederatedWorker(args: { const setupDecision = createsWorktree ? (params.setup ?? 'run') : 'not_applicable' const started = db.createStartingWorkerDispatch({ - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, args.callerSession), maxDepth: runtime.getNestedWorkerMaxDepth(), taskId: task?.id, taskSpec: params.spec, diff --git a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts index 29be91b4324..f0736db916c 100644 --- a/src/main/runtime/rpc/methods/orchestration/gates/gates.ts +++ b/src/main/runtime/rpc/methods/orchestration/gates/gates.ts @@ -84,7 +84,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateCreate', params: GateCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() let options: string[] | undefined if (params.options) { @@ -107,7 +110,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) if (task.run_id !== run.id) { throw taskNotFoundError(`Task ${params.task} was not found in Run ${run.id}.`, { @@ -127,7 +131,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateResolve', params: GateResolveParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const existing = db.getGate(params.id) if (!existing) { @@ -138,7 +145,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) // Why: a gate outside the caller's Run is indistinguishable from a missing one, so probing cannot map foreign Runs. if (existing.run_id !== run.id) { @@ -155,7 +163,10 @@ export const ORCHESTRATION_GATE_METHODS = [ defineMethod({ name: 'orchestration.gateList', params: GateListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const explicitRun = params.run ? db.getRun(params.run) : undefined // Why: same read posture as taskList — an explicitly named Run is inspectable, an unnamed one means the caller's own. @@ -167,7 +178,8 @@ export const ORCHESTRATION_GATE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: params.run === undefined, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const gates = db .listGates({ diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index 20ac25e6512..985ad08ae57 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -6,6 +6,8 @@ import { checkRunMailbox } from './check-run' import { checkWorkerMailbox } from './check-worker' import { checkDirectMailbox } from './check-direct' import { orchestrationSkillRecoveryData } from '../../../../../../shared/orchestration-rpc-contract' +import { hasRunBindingKey } from '../../../../orchestration/orchestration-caller-identity' +import { orchestrationCallerIdentity } from '../runs/run-scope' import { callerHoldsDispatchPane, dispatchFenced, @@ -20,6 +22,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, signal, legacyCoordinatorRunId, @@ -31,9 +34,14 @@ export const ORCHESTRATION_CHECK_METHODS = [ const handle = params.terminal ?? 'unknown' const typeFilter = parseMessageTypes(params.types) - // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. - const paneKey = runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + const caller = orchestrationCallerIdentity(runtime, { + handle, + session: orchestrationCaller, + // Why: a live runtime handle is authoritative; pane metadata is only the restart fallback. + paneKey: runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey + }) + const paneKey = caller.paneKey ?? undefined + const boundRun = db.getCurrentRunForCoordinator(caller) if (params.run || boundRun) { return checkRunMailbox({ params, @@ -41,6 +49,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ db, handle, paneKey, + callerSession: orchestrationCaller, typeFilter, signal, legacyCoordinatorRunId, @@ -93,7 +102,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ } // Why: a consuming check on a handle with no live pane and no Dispatch can never see // Run mail, so an empty inbox would read as "nothing yet" instead of a stale caller. - if (!paneKey && consumingCheck) { + if (!hasRunBindingKey(caller) && consumingCheck) { throw new OrchestrationError( 'stable_pane_required', `Terminal ${handle} has no live pane bound to a Run, so this inbox can never receive Run mail. Rebind this terminal with orchestration run-use, or read the Run mailbox with --run .`, diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts index 6db89cf4a20..248d79ea68e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-run.ts @@ -9,6 +9,7 @@ import { routeAllMailboxPages } from '../schemas' import { resolveRunScope } from '../runs/run-scope' import type { CheckParams } from '../schemas' import type { z } from 'zod' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' type CheckParamsInput = z.infer @@ -18,6 +19,7 @@ export async function checkRunMailbox(args: { db: OrchestrationDb handle: string paneKey: string | undefined + callerSession: OrchestrationSessionCaller | undefined typeFilter: MessageType[] | undefined signal: AbortSignal | undefined legacyCoordinatorRunId: string | undefined @@ -31,6 +33,7 @@ export async function checkRunMailbox(args: { db, handle, paneKey, + callerSession, typeFilter, signal, legacyCoordinatorRunId, @@ -52,6 +55,7 @@ export async function checkRunMailbox(args: { runId: params.run, callerTerminalHandle: handle, callerPaneKey: paneKey, + callerSession, requireCurrentConsumer: true, legacyCoordinatorRunId, callerEvidence: orchestrationCompatibilityEvidence @@ -73,6 +77,7 @@ export async function checkRunMailbox(args: { runId: run.id, callerTerminalHandle: handle, callerPaneKey: paneKey, + callerSession, requireCurrentConsumer: true, legacyCoordinatorRunId, callerEvidence: orchestrationCompatibilityEvidence diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts index 51a9d9bc0c5..1b6edaf813e 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/message-methods.ts @@ -4,7 +4,7 @@ import { OrchestrationError } from '../../../../orchestration/orchestration-erro import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../../../shared/orchestration-rpc-contract' import { abbreviateOrchestrationTasks } from '../../../../../../shared/orchestration-task-summary' import { parseOrchestrationTaskDepsFlag } from '../../../../orchestration/task-deps-flag' -import { resolveRunScope } from '../runs/run-scope' +import { orchestrationCallerIdentity, resolveRunScope } from '../runs/run-scope' import { readMutationReplayNudge, stripMutationReplayNudge @@ -27,6 +27,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, legacyCoordinatorRunId, recordMutationReceipt, @@ -73,7 +74,8 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const answered = db.answerQuestion({ messageId: question.message_id, @@ -144,7 +146,10 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskCreate', params: TaskCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const deps = params.deps ? parseOrchestrationTaskDepsFlag(params.deps) : undefined const run = resolveRunScope(runtime, { @@ -152,10 +157,19 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) - const creatorAuthority = params.callerTerminalHandle - ? runtime.getOrchestrationDispatchAuthority(params.callerTerminalHandle) + // A handle-less session creates root Tasks: Task lineage is recorded by terminal only. + const creatorHandle = params.callerTerminalHandle + ? orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: orchestrationCaller, + paneKey: null + }).terminalHandle + : null + const creatorAuthority = creatorHandle + ? runtime.getOrchestrationDispatchAuthority(creatorHandle) : null const task = db.createTask({ spec: params.spec, @@ -163,7 +177,7 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ displayName: params.displayName, deps, parentId: params.parent, - createdByTerminalHandle: params.callerTerminalHandle, + createdByTerminalHandle: creatorHandle ?? undefined, ...(creatorAuthority?.paneKey && creatorAuthority.processIncarnation ? { createdByPaneKey: creatorAuthority.paneKey, @@ -180,7 +194,10 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskList', params: TaskListParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const explicitRun = params.run ? db.getRun(params.run) : undefined const run = @@ -191,7 +208,8 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: params.run === undefined, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) // Why: listTasksWithDispatch adds assignee_handle + dispatch_id (NULL for non-dispatched), so legacy-shape consumers are unaffected. const joined = db.listTasksWithDispatch({ @@ -218,14 +236,18 @@ export const ORCHESTRATION_MESSAGE_METHODS = [ defineMethod({ name: 'orchestration.taskUpdate', params: TaskUpdateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime, legacyCoordinatorRunId }) => { + handler: ( + params, + { orchestrationCompatibilityEvidence, orchestrationCaller, runtime, legacyCoordinatorRunId } + ) => { const db = runtime.getOrchestrationDb() const run = resolveRunScope(runtime, { runId: params.run, callerTerminalHandle: params.callerTerminalHandle, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) const existing = db.getTask(params.id) if (!existing || existing.run_id !== run.id) { diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts index d5ee366a40f..cb75bca2705 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-group.ts @@ -13,6 +13,7 @@ import { exposeMessages } from './mailbox-message-receipt' import { recordReceiptBeforeNudge } from './mutation-replay-nudge' import type { BareRecipientResolution, SendRecipientWarning } from './recipient-routing' import type { SendParams } from '../schemas' +import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' import type { z } from 'zod' type SendParamsInput = z.infer @@ -94,6 +95,7 @@ export async function sendGroupMessage(args: { db: OrchestrationDb from: string groupAddress: string + sender: OrchestrationCallerIdentity senderPaneKey: string | undefined senderRunId: string | undefined explicitRunId: string | undefined @@ -108,6 +110,7 @@ export async function sendGroupMessage(args: { db, from, groupAddress, + sender, senderPaneKey, senderRunId, explicitRunId, @@ -117,7 +120,7 @@ export async function sendGroupMessage(args: { } = args // Audience follows the sender's binding, never a caller-supplied message Run or payload. function resolveAudienceRunId(): string { - const coordinated = senderPaneKey ? db.getCurrentRunForPane(senderPaneKey) : undefined + const coordinated = db.getCurrentRunForCoordinator(sender) const runId = coordinated?.id ?? db.getActiveDispatchForIdentity(from, senderPaneKey)?.run_id ?? diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts index 7d1edb77602..28f5a4d67d5 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/send-methods.ts @@ -19,6 +19,7 @@ import { sendRemoteMessage } from './send-remote' import { sendPointToPointMessage } from './send-point-to-point' import { sendGroupMessage } from './send-group' import { sendFederatedControlMail } from './send-control-mail' +import { orchestrationCallerIdentity } from '../runs/run-scope' export const ORCHESTRATION_SEND_METHODS = [ defineMethod({ @@ -35,6 +36,7 @@ export const ORCHESTRATION_SEND_METHODS = [ recordMutationReceipt, markWorkerDoneMutationEffectFree, replayedMutationReceipt, + orchestrationCaller, signal } ) => { @@ -59,7 +61,12 @@ export const ORCHESTRATION_SEND_METHODS = [ ? orchestrationCompatibilityCallerAuthority : undefined // Why: attested hook identity survives graph remount; caller params never supply lifecycle authority. - const senderPaneKey = attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) ?? undefined + const sender = orchestrationCallerIdentity(runtime, { + handle: from, + session: orchestrationCaller, + paneKey: attestedCaller?.paneKey ?? runtime.getTerminalPaneKey(from) + }) + const senderPaneKey = sender.paneKey ?? undefined const remoteAttachment = senderPaneKey ? db.findActiveRemoteAttachmentForPane(senderPaneKey) : undefined @@ -84,8 +91,7 @@ export const ORCHESTRATION_SEND_METHODS = [ params.to && isGroupAddress(params.to) && !params.to.toLowerCase().startsWith('@worktree:') // Run groups validate their own audience; message scope cannot select a parent Dispatch. const routing = resolveMessageRun(runtime, { - from, - senderPaneKey, + sender, to: params.to, runId: runGroup ? undefined : params.run, payload: runGroup ? undefined : params.payload @@ -199,6 +205,7 @@ export const ORCHESTRATION_SEND_METHODS = [ db, from, groupAddress: to, + sender, senderPaneKey, senderRunId: routing.run?.id, explicitRunId: params.run, diff --git a/src/main/runtime/rpc/methods/orchestration/routing.ts b/src/main/runtime/rpc/methods/orchestration/routing.ts index 47001283895..5978eb60cd1 100644 --- a/src/main/runtime/rpc/methods/orchestration/routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/routing.ts @@ -1,6 +1,7 @@ import type { MessageType } from '../../../orchestration/db' import type { RunRow } from '../../../orchestration/types' import type { OrcaRuntimeService } from '../../../orca-runtime' +import type { OrchestrationCallerIdentity } from '../../../orchestration/orchestration-caller-identity' import { MESSAGE_TYPES } from '../../../orchestration/types' import { OrchestrationError } from '../../../orchestration/orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../../../orchestration/db' @@ -20,8 +21,8 @@ export function parseMessageTypes(rawTypes: string | undefined): MessageType[] | export function resolveMessageRun( runtime: OrcaRuntimeService, params: { - from?: string - senderPaneKey?: string + /** The sender as Run binding and Dispatch identity see it. */ + sender: OrchestrationCallerIdentity to?: string runId?: string payload?: string @@ -50,9 +51,7 @@ export function resolveMessageRun( const dispatch = dispatchId ? db.getDispatchContextById(dispatchId) - : params.from - ? db.getActiveDispatchForIdentity(params.from, params.senderPaneKey) - : undefined + : db.getActiveDispatchForIdentity(params.sender.address, params.sender.paneKey ?? undefined) if (params.to?.startsWith('dispatch:') && !dispatch) { throw new OrchestrationError( 'dispatch_not_found', @@ -63,9 +62,8 @@ export function resolveMessageRun( const resolvedRunId = params.runId ?? targetRunId ?? dispatch?.run_id let run = resolvedRunId ? db.getRun(resolvedRunId) : undefined - if (!run && params.from) { - const paneKey = params.senderPaneKey ?? runtime.getTerminalPaneKey(params.from) - run = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + if (!run) { + run = db.getCurrentRunForCoordinator(params.sender) } if (resolvedRunId && (!run || run.legacy === 1)) { throw new OrchestrationError('run_not_found', `Run ${resolvedRunId} was not found.`) diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts index 8cdbcb8da11..0e7bbff5619 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts @@ -1,5 +1,7 @@ import type { DispatchCreator } from '../../../../orchestration/db/dispatch-depth' +import type { OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { orchestrationCallerIdentity } from './run-scope' /** * Identify a CLI caller for nesting-depth purposes. @@ -10,18 +12,33 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' */ export function resolveDispatchCreator( runtime: OrcaRuntimeService, - callerHandle: string | undefined + callerHandle: string | undefined, + callerSession?: OrchestrationSessionCaller ): DispatchCreator { if (!callerHandle) { // No declared caller means no resolvable parent. Depth 0 is the same answer // the pre-existing Run-binding check already gives this case. return { kind: 'system' } } - const authority = runtime.getOrchestrationDispatchAuthority?.(callerHandle) + const caller = orchestrationCallerIdentity(runtime, { + handle: callerHandle, + session: callerSession, + paneKey: null + }) + if (caller.terminalHandle === null) { + // A handle-less session: its actor is its whole identity. + return caller.actor ? { kind: 'actor', actor: caller.actor } : { kind: 'system' } + } + const authority = runtime.getOrchestrationDispatchAuthority?.(caller.terminalHandle) return { kind: 'terminal', - handle: callerHandle, - paneKey: authority?.paneKey ?? runtime.getTerminalPaneKey(callerHandle) ?? undefined, - processIncarnation: authority?.processIncarnation ?? undefined + handle: caller.terminalHandle, + paneKey: + authority?.paneKey ?? + caller.paneKey ?? + runtime.getTerminalPaneKey(caller.terminalHandle) ?? + undefined, + processIncarnation: authority?.processIncarnation ?? undefined, + ...(caller.actor ? { actor: caller.actor } : {}) } } 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 75c83878335..e3a73e60194 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts @@ -21,6 +21,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ params, { orchestrationCompatibilityEvidence, + orchestrationCaller, runtime, legacyCoordinatorRunId, revalidateLegacyCoordinator, @@ -37,7 +38,8 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ callerTerminalHandle: params.from, requireCurrentConsumer: true, legacyCoordinatorRunId, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) if (task.run_id !== run.id) { throw taskNotFoundError(`Task ${task.id} was not found in Run ${run.id}.`, { @@ -50,7 +52,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ if (params.dryRun) { const maxDepth = runtime.getNestedWorkerMaxDepth() const previewDepth = db.resolveChildDispatchDepth( - resolveDispatchCreator(runtime, params.from), + resolveDispatchCreator(runtime, params.from, orchestrationCaller), maxDepth ) const preamble = buildDispatchPreamble({ @@ -131,7 +133,7 @@ export const ORCHESTRATION_DISPATCH_METHODS = [ assigneePaneKey, launchTokenHash: dispatchAuthority?.launchTokenHash ?? undefined, processIncarnation, - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, orchestrationCaller), maxDepth: runtime.getNestedWorkerMaxDepth() }) const dispatchCapability = params.inject diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts index 6b066723315..f36c941f6ef 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts @@ -6,11 +6,20 @@ import type { OrcaRuntimeService, OrchestrationCompatibilityCallerAuthority } from '../../../../orca-runtime' +import { + hasRunBindingKey, + type OrchestrationCallerIdentity, + type OrchestrationSessionCaller +} from '../../../../orchestration/orchestration-caller-identity' +import { resolveStructuredWorkerIdentity } from '../../../../structured-worker-authority' +import { formatOrchestrationActor } from '../../../../../../shared/orchestration-actor' export type RunScopeParams = { runId?: string callerTerminalHandle?: string callerPaneKey?: string + /** Resolved at the dispatch entry; when set it is the caller, whatever the declared handle. */ + callerSession?: OrchestrationSessionCaller requireCurrentConsumer: boolean legacyCoordinatorRunId?: string // Why: the caller's declared handle is a user param; this is the attested one to check it against. @@ -36,10 +45,36 @@ export function assertCallerHandleMatchesEvidence( } } +/** + * The caller as Run binding and mail routing see it. A session the dispatch entry resolved is the + * caller outright; otherwise it is the declared terminal handle and the pane it resolved to, plus + * the actor a structured worker's handle was minted for. + */ +export function orchestrationCallerIdentity( + runtime: OrcaRuntimeService, + caller: { + handle: string + paneKey: string | null | undefined + session?: OrchestrationSessionCaller + } +): OrchestrationCallerIdentity { + if (caller.session) { + return caller.session + } + const worker = resolveStructuredWorkerIdentity(caller.handle, runtime.getOrchestrationDb()) + return { + address: caller.handle, + terminalHandle: caller.handle, + paneKey: caller.paneKey ?? null, + actor: worker ? formatOrchestrationActor({ kind: 'session', id: worker.sessionId }) : null + } +} + export type OrchestrationCallerParams = { callerTerminalHandle: string callerEvidence?: OrchestrationCompatibilityEvidence callerAuthority?: OrchestrationCompatibilityCallerAuthority + callerSession?: OrchestrationSessionCaller /** Preserve legacy callers that treated a missing pane as an ordinary fence. */ requireStablePane?: boolean /** @@ -50,33 +85,42 @@ export type OrchestrationCallerParams = { evidenceAssertedByCaller?: boolean } -/** Resolve the caller's runtime pane and, by default, attest its declared handle. */ +/** Resolve the caller's identity and, by default, attest its declared handle. */ export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams & { requireStablePane: true } -): string +): OrchestrationCallerIdentity export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams -): string | null +): OrchestrationCallerIdentity | null export function resolveOrchestrationCaller( runtime: OrcaRuntimeService, params: OrchestrationCallerParams -): string | null { +): OrchestrationCallerIdentity | null { if (!params.evidenceAssertedByCaller) { assertCallerHandleMatchesEvidence(runtime, params.callerTerminalHandle, params.callerEvidence) } - const paneKey = - params.callerAuthority?.terminalHandle === params.callerTerminalHandle - ? params.callerAuthority.paneKey - : runtime.getTerminalPaneKey(params.callerTerminalHandle) - if (!paneKey && params.requireStablePane) { - throw new OrchestrationError( - 'stable_pane_required', - 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' - ) + const caller = orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: params.callerSession, + paneKey: + params.callerSession === undefined + ? params.callerAuthority?.terminalHandle === params.callerTerminalHandle + ? params.callerAuthority.paneKey + : runtime.getTerminalPaneKey(params.callerTerminalHandle) + : undefined + }) + if (!hasRunBindingKey(caller)) { + if (params.requireStablePane) { + throw new OrchestrationError( + 'stable_pane_required', + 'The coordinator terminal has no stable pane identity. Run this command inside a live Orca terminal.' + ) + } + return null } - return paneKey ?? null + return caller } // Why: task and gate mutations must share one Run-binding rule. @@ -101,14 +145,21 @@ export function resolveRunScope(runtime: OrcaRuntimeService, params: RunScopePar if (explicit && params.legacyCoordinatorRunId === explicit.id) { return explicit } - const paneKey = params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle) - if (!paneKey) { + const caller = orchestrationCallerIdentity(runtime, { + handle: params.callerTerminalHandle, + session: params.callerSession, + paneKey: + params.callerSession === undefined + ? (params.callerPaneKey ?? runtime.getTerminalPaneKey(params.callerTerminalHandle)) + : undefined + }) + if (!hasRunBindingKey(caller)) { throw new OrchestrationError( 'stable_pane_required', 'The coordinator terminal has no stable pane identity.' ) } - const current = db.getCurrentRunForPane(paneKey) + const current = db.getCurrentRunForCoordinator(caller) if (!current) { if (explicit) { throw new OrchestrationError( diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index eab6eb2913e..26fb51641a6 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -14,18 +14,20 @@ export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCreate', params: RunCreateParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime }) => { - const paneKey = resolveOrchestrationCaller(runtime, { + handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller, requireStablePane: true }) const db = runtime.getOrchestrationDb() - const priorRun = db.getCurrentRunForPane(paneKey) + const priorRun = db.getCurrentRunForCoordinator(caller) const run = db.createRun({ objective: params.objective, - coordinatorHandle: params.from, - coordinatorPaneKey: paneKey + coordinatorHandle: caller.terminalHandle, + coordinatorPaneKey: caller.paneKey, + coordinatorActor: caller.actor }) runtime.cancelMessageWaiters(params.from) if (priorRun) { @@ -43,19 +45,22 @@ export const ORCHESTRATION_RUN_METHODS = [ runtime, legacyCoordinatorAuthority, orchestrationCompatibilityEvidence, - orchestrationCompatibilityCallerAuthority: callerAuthority + orchestrationCompatibilityCallerAuthority: callerAuthority, + orchestrationCaller } ) => { - const paneKey = resolveOrchestrationCaller(runtime, { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, callerAuthority, + callerSession: orchestrationCaller, requireStablePane: true, evidenceAssertedByCaller: true }) if ( params.takeoverLegacy && - (callerAuthority?.terminalHandle !== params.from || callerAuthority.paneKey !== paneKey) + (callerAuthority?.terminalHandle !== params.from || + callerAuthority.paneKey !== caller.paneKey) ) { throw new OrchestrationError( 'legacy_read_only', @@ -65,11 +70,12 @@ export const ORCHESTRATION_RUN_METHODS = [ } assertCallerHandleMatchesEvidence(runtime, params.from, orchestrationCompatibilityEvidence) const db = runtime.getOrchestrationDb() - const priorRun = db.getCurrentRunForPane(paneKey) + const priorRun = db.getCurrentRunForCoordinator(caller) const run = db.bindRun({ runId: params.id, - coordinatorHandle: params.from, - coordinatorPaneKey: paneKey, + coordinatorHandle: caller.terminalHandle, + coordinatorPaneKey: caller.paneKey, + coordinatorActor: caller.actor, takeoverLegacy: params.takeoverLegacy, legacyCoordinatorAuthority }) @@ -90,13 +96,14 @@ export const ORCHESTRATION_RUN_METHODS = [ defineMethod({ name: 'orchestration.runCurrent', params: RunCurrentParams, - handler: (params, { orchestrationCompatibilityEvidence, runtime }) => { - const paneKey = resolveOrchestrationCaller(runtime, { + handler: (params, { orchestrationCompatibilityEvidence, orchestrationCaller, runtime }) => { + const caller = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller, requireStablePane: true }) - const run = runtime.getOrchestrationDb().getCurrentRunForPane(paneKey) + const run = runtime.getOrchestrationDb().getCurrentRunForCoordinator(caller) return { run: run ? exposeRun(run) : null } } }), diff --git a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts index 6e20cc40a29..f9b9e195e21 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/explicit-worker-terminal-validation.ts @@ -1,6 +1,7 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { OrchestrationError } from '../../../../orchestration/orchestration-error' import { isStructuredWorkerHandle } from '../../../../structured-worker-identity' +import type { OrchestrationCallerIdentity } from '../../../../orchestration/orchestration-caller-identity' /** * Admits a caller-supplied `--terminal` as this dispatch's worker pane. @@ -13,18 +14,22 @@ export async function assertExplicitWorkerTerminalUsable(args: { runtime: OrcaRuntimeService terminal: string from: string - coordinatorPane: string | null + coordinator: OrchestrationCallerIdentity | null resolvedWorktreeId: string | undefined }): Promise { - const { runtime, terminal, from, coordinatorPane, resolvedWorktreeId } = args + const { runtime, terminal, from, coordinator, resolvedWorktreeId } = args const explicitTerminal = await runtime.showTerminal(terminal) const targetPane = runtime.getTerminalPaneKey(terminal) - const callerPane = coordinatorPane ?? runtime.getTerminalPaneKey(from) + const callerPane = coordinator?.paneKey ?? runtime.getTerminalPaneKey(from) // A structured coordinator has no terminal to show, so its own identity is the raw handle plus - // the pane key; showing `from` unconditionally would throw for exactly those callers. - const coordinatorHandle = isStructuredWorkerHandle(from) - ? from - : (await runtime.showTerminal(from)).handle + // the pane key; showing `from` unconditionally would throw for exactly those callers. A + // handle-less session has no terminal at all, so its address is its identity. + const coordinatorHandle = + coordinator?.terminalHandle === null + ? coordinator.address + : isStructuredWorkerHandle(from) + ? from + : (await runtime.showTerminal(from)).handle if ( explicitTerminal.handle === coordinatorHandle || (targetPane !== null && targetPane === callerPane) diff --git a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts index f8cd1033c97..d32232f8447 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/local-worker-start.ts @@ -2,6 +2,10 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' import { describeTerminalWaitBlockedReason } from '../../../../../../shared/terminal-wait-blocked-reason-legacy-alias' import type { OrchestrationDb } from '../../../../orchestration/db' import type { RunRow, TaskRow } from '../../../../orchestration/types' +import type { + OrchestrationCallerIdentity, + OrchestrationSessionCaller +} from '../../../../orchestration/orchestration-caller-identity' import { resolveDispatchCreator } from '../runs/dispatch-creator' import { resolveDispatchCallerWorktreeId } from '../../orchestration-caller-workspace' import { @@ -38,18 +42,25 @@ export async function startLocalWorker(args: { runtime: OrcaRuntimeService db: OrchestrationDb run: RunRow - coordinatorPane: string | null + coordinator: OrchestrationCallerIdentity | null + callerSession?: OrchestrationSessionCaller existingTask?: TaskRow orchestrationMutation?: WorkerStartMutation /** Settings-driven; the executing host still gets to refuse below. */ mode: WorkerStartModeReceipt }): Promise { - const { params, runtime, db, run, coordinatorPane, existingTask, orchestrationMutation } = args + const { params, runtime, db, run, coordinator, callerSession, existingTask } = args + const { orchestrationMutation } = args + const coordinatorPane = coordinator?.paneKey ?? null const requestedWorktree = params.worktree ?? 'current' const createsWorktree = requestedWorktree === 'new-child' || requestedWorktree === 'new-top-level' const { agent, launch } = prepareLocalWorkerStart({ params, createsWorktree, runtime }) - const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId(runtime, params.from) + const coordinatorWorktreeId = await resolveDispatchCallerWorktreeId( + runtime, + params.from, + callerSession + ) const creationWorktree = createsWorktree ? await runtime.showManagedWorktree(`id:${coordinatorWorktreeId}`) : undefined @@ -70,7 +81,7 @@ export async function startLocalWorker(args: { runtime, terminal: params.terminal, from: params.from, - coordinatorPane, + coordinator, resolvedWorktreeId: resolvedWorktree?.id }) } @@ -95,7 +106,7 @@ export async function startLocalWorker(args: { : 'existing_worktree' } const started = db.createStartingWorkerDispatch({ - creator: resolveDispatchCreator(runtime, params.from), + creator: resolveDispatchCreator(runtime, params.from, callerSession), maxDepth: runtime.getNestedWorkerMaxDepth(), taskId: existingTask?.id, taskSpec: params.spec, @@ -103,10 +114,12 @@ export async function startLocalWorker(args: { taskDeps: parseTaskDeps(params.deps), taskParentId: params.parent, taskRunId: run.id, - taskCreatedByTerminalHandle: params.from, + // A handle-less session creates root Tasks: Task lineage is recorded by terminal only. + taskCreatedByTerminalHandle: coordinator?.terminalHandle ?? undefined, taskCreatedByPaneKey: coordinatorPane ?? undefined, - taskCreatedByProcessIncarnation: - runtime.getTerminalProcessIncarnation(params.from) ?? undefined, + taskCreatedByProcessIncarnation: coordinator?.terminalHandle + ? (runtime.getTerminalProcessIncarnation(coordinator.terminalHandle) ?? undefined) + : undefined, taskCreatedByRunGeneration: run.consumer_generation, retryOf: params.retryOf, startOptions, diff --git a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts index b1a1f40d45a..be4e40ab2b6 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/workers.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/workers.ts @@ -20,7 +20,7 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ params: WorkerStartParams, handler: async ( params, - { runtime, orchestrationMutation, orchestrationCompatibilityEvidence } + { runtime, orchestrationMutation, orchestrationCompatibilityEvidence, orchestrationCaller } ) => { if (!isWorkerStartTimeoutWithinTimerLimit(params.timeoutMs)) { throw new OrchestrationError( @@ -30,11 +30,12 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ } const readinessTimeoutMs = resolveWorkerStartReadinessTimeoutMs(params.timeoutMs) const db = runtime.getOrchestrationDb() - const coordinatorPane = resolveOrchestrationCaller(runtime, { + const coordinator = resolveOrchestrationCaller(runtime, { callerTerminalHandle: params.from, - callerEvidence: orchestrationCompatibilityEvidence + callerEvidence: orchestrationCompatibilityEvidence, + callerSession: orchestrationCaller }) - const run = coordinatorPane ? db.getCurrentRunForPane(coordinatorPane) : undefined + const run = coordinator ? db.getCurrentRunForCoordinator(coordinator) : undefined if (!run || (params.run && params.run !== run.id)) { throw new OrchestrationError( 'consumer_fenced', @@ -62,7 +63,8 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ db, runId: run.id, task: existingTask, - orchestrationMutation + orchestrationMutation, + callerSession: orchestrationCaller }) return receipt && typeof receipt === 'object' ? { ...receipt, mode } : receipt } @@ -71,7 +73,8 @@ export const ORCHESTRATION_WORKER_START_METHODS = [ runtime, db, run, - coordinatorPane, + coordinator, + callerSession: orchestrationCaller, existingTask, orchestrationMutation, mode diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index e6ad7952d8c..719dcddee01 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -53,7 +53,9 @@ export class OrchestrationMutationExecutor { request: RpcRequest, params: unknown, invoke: (mutation?: DurableMutationInvocation) => unknown, - callerFingerprintOverride?: string + callerFingerprintOverride?: string, + /** The resolved session actor; it joins the payload so another caller cannot replay it. */ + callerActor?: string ): Promise { const requestId = request.orchestrationRequestId if (!requestId || !isDurableMutation(request.method, params)) { @@ -61,7 +63,7 @@ export class OrchestrationMutationExecutor { } const callerFingerprint = callerFingerprintOverride ?? this.getLocalAuthenticatedCallerFingerprint() - const stableParams = replayStableCallerParams(this.runtime, params) + const stableParams = replayStableCallerParams(this.runtime, params, callerActor) const basePayloadHash = hashCanonical({ method: request.method, params: stableParams }) const key = `${callerFingerprint}:${requestId}` const db = this.runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/orchestration-mutation-receipt.ts b/src/main/runtime/rpc/orchestration-mutation-receipt.ts index 25a3fa1c177..a535618c306 100644 --- a/src/main/runtime/rpc/orchestration-mutation-receipt.ts +++ b/src/main/runtime/rpc/orchestration-mutation-receipt.ts @@ -13,12 +13,21 @@ export type MutationReplayNudge = | { kind: 'messages'; targets: { to: string; type: string }[] } | { kind: 'federation'; runId?: string } -export function replayStableCallerParams(runtime: OrcaRuntimeService, params: unknown): unknown { +const CALLER_ACTOR_KEY = '__orcaCallerActor' + +export function replayStableCallerParams( + runtime: OrcaRuntimeService, + params: unknown, + callerActor?: string +): unknown { if (!params || typeof params !== 'object' || Array.isArray(params)) { return params } const source = params as Record - const result = { ...source } + // Absent for terminal callers, so their payload hashes are unchanged. + const result: Record = callerActor + ? { ...source, [CALLER_ACTOR_KEY]: callerActor } + : { ...source } delete result.waitSubmitMs for (const property of ['from', 'callerTerminalHandle', 'terminal'] as const) { const handle = source[property] diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts new file mode 100644 index 00000000000..466f1408bf5 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -0,0 +1,230 @@ +/** + * Resolves an orchestration caller that names itself by the Orca agent session id in its injected + * environment. Both dispatchers call this once, after params parse and before the unary/streaming + * split, so it runs ahead of legacy compatibility, receipt lookup and every method. + * + * The id names the caller; nothing here is a credential. Every agent on this host runs as the same + * user, so the checks are about getting the identity right, not about keeping anyone out: + * - Same host only. A paired client, an SSH environment or a WSL shell is another host, where + * "same machine, same user" does not hold, so a session claim from one is refused. + * - The Orca id, never the provider's: that one rotates on `/clear`. + * - A live lease under either owner (native chat or terminal view), so a handoff keeps the actor. + * - The session wins over any declared caller: a declared handle must name this same session, and + * a structured worker's session id maps to the handle and pane it was minted. + */ +import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + formatOrchestrationActor, + sessionOrchestrationActor +} from '../../../shared/orchestration-actor' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' +import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' +import { OrchestrationError } from '../orchestration/orchestration-error' +import { resolveStructuredWorkerIdentityForSession } from '../structured-worker-authority' +import { structuredWorkerHostScope } from '../structured-worker-identity' +import type { RpcRequest } from './core' + +type CallerParam = 'from' | 'terminal' | 'callerTerminalHandle' + +/** + * Every method that consults caller identity, and the param it names its caller in. This list is + * the contract: a method that starts reading caller identity is added here with its own test. + * Methods not listed carry no caller identity for any actor; a session claim on them is still + * validated, then they run exactly as they do for a terminal caller. + */ +export const ORCHESTRATION_CALLER_PARAM: Readonly> = { + 'orchestration.runCreate': 'from', + 'orchestration.runUse': 'from', + 'orchestration.runCurrent': 'from', + 'orchestration.check': 'terminal', + 'orchestration.send': 'from', + 'orchestration.reply': 'from', + 'orchestration.ask': 'from', + 'orchestration.dispatch': 'from', + 'orchestration.gateCreate': 'from', + 'orchestration.gateResolve': 'from', + 'orchestration.gateList': 'from', + 'orchestration.taskCreate': 'callerTerminalHandle', + 'orchestration.taskList': 'callerTerminalHandle', + 'orchestration.taskUpdate': 'callerTerminalHandle', + 'orchestration.workerStart': 'from' +} + +export type OrchestrationRequestRoute = { + /** Set only by the paired WebSocket route: the request came from another host's client. */ + pairedDeviceId?: string +} + +export type ResolvedOrchestrationRequest = { + request: RpcRequest + params: unknown + caller?: OrchestrationSessionCaller +} + +const NO_EFFECTS = { effectsApplied: false } as const + +export async function resolveOrchestrationSessionCaller( + runtime: OrcaRuntimeService, + request: RpcRequest, + params: unknown, + route: OrchestrationRequestRoute | undefined +): Promise { + const evidence = request.orchestrationCompatibilityEvidence + const claimed: unknown = evidence?.agentSessionId + if (!request.method.startsWith('orchestration.') || claimed === undefined) { + return { request, params } + } + if (route?.pairedDeviceId !== undefined) { + throw hostBoundary( + 'This request reached Orca from a paired client, and an agent session id identifies a caller only on the host that runs that session.' + ) + } + if (evidence?.host) { + throw hostBoundary( + `This command ran in ${evidence.host.kind === 'ssh' ? 'an SSH' : 'a WSL'} environment, and an agent session id identifies a caller only on the host that runs that session.` + ) + } + const actor = sessionOrchestrationActor(typeof claimed === 'string' ? claimed : '') + if (!actor) { + throw new OrchestrationError( + CODES.unknown, + 'The caller named an agent session id that is not an Orca session id. No effects were applied.', + NO_EFFECTS + ) + } + const sessionId = actor.id + const record = await readSessionRecord(runtime, sessionId) + assertSessionCanAct(sessionId, record) + const worker = resolveStructuredWorkerIdentityForSession(sessionId, runtime.getOrchestrationDb()) + const terminalHandle = worker?.handle ?? null + const caller: OrchestrationSessionCaller = Object.freeze({ + sessionId, + actor: formatOrchestrationActor(actor), + address: terminalHandle ?? formatOrchestrationActor(actor), + terminalHandle, + paneKey: worker?.paneKey ?? null, + workspaceId: record.location.workspaceId + }) + return { + // Why: the session wins, so terminal evidence inherited from a terminal view never attests. + request: { ...request, orchestrationCompatibilityEvidence: { agentSessionId: sessionId } }, + params: bindDeclaredCaller(request.method, params, caller), + caller + } +} + +async function readSessionRecord( + runtime: OrcaRuntimeService, + sessionId: string +): Promise { + let store: ReturnType + try { + await runtime.ensureStructuredAgentSessionHost() + store = sessionRecordStore() + } catch { + store = null + } + if (!store) { + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} cannot be verified: this Orca is not running its agent-session host. No effects were applied.`, + NO_EFFECTS + ) + } + const record = store.getRecord(sessionId) + if (record) { + return record + } + const owner = store.listRecords().find((candidate) => namesProviderSession(candidate, sessionId)) + if (owner) { + throw new OrchestrationError( + CODES.providerId, + `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca id is ${owner.sessionId}; use that instead. No effects were applied.`, + { ...NO_EFFECTS, orcaSessionId: owner.sessionId } + ) + } + throw new OrchestrationError( + CODES.unknown, + `No Orca agent session ${sessionId} exists on this host. No effects were applied.`, + NO_EFFECTS + ) +} + +function sessionRecordStore(): { + getRecord: (sessionId: string) => AgentSessionRecord | null + listRecords: () => AgentSessionRecord[] +} | null { + return getStructuredAgentSessionHost()?.deps.store ?? null +} + +function namesProviderSession(record: AgentSessionRecord, id: string): boolean { + return record.providerHandleChain.some(({ handle }) => + handle.provider === 'claude' ? handle.sessionId === id : handle.threadId === id + ) +} + +function assertSessionCanAct(sessionId: string, record: AgentSessionRecord): void { + if (!structuredWorkerHostScope(record.location)) { + throw hostBoundary( + `Agent session ${sessionId} runs on another host, and an agent session id identifies a caller only on the host that runs that session.` + ) + } + if (agentSessionLeaseAdmitsWriter(record.lease)) { + return + } + const { lease } = record + const reason = + lease.claimStatus === 'released' + ? 'has ended, so it can no longer act in orchestration.' + : lease.handoffStage !== null + ? 'is switching between chat and terminal view. Retry when the switch finishes.' + : 'has no live owner on this host right now. Retry once it is running.' + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} ${reason} No effects were applied.`, + NO_EFFECTS + ) +} + +/** The declared caller, if any, must name this session; it is then replaced by its address. */ +function bindDeclaredCaller( + method: string, + params: unknown, + caller: OrchestrationSessionCaller +): unknown { + const name = ORCHESTRATION_CALLER_PARAM[method] + if (!name || !params || typeof params !== 'object' || Array.isArray(params)) { + return params + } + const values: Record = { ...params } + const declared = values[name] + const names: unknown[] = [caller.address, caller.actor, caller.sessionId, caller.terminalHandle] + if (declared !== undefined && !names.includes(declared)) { + throw consumerFenced(caller, String(declared)) + } + // Why: check's restart fallback takes a pane key from the caller; a session has its own or none. + if (values.terminalPaneKey !== undefined && values.terminalPaneKey !== caller.paneKey) { + throw consumerFenced(caller, `pane ${String(values.terminalPaneKey)}`) + } + values[name] = caller.address + return values +} + +function consumerFenced(caller: OrchestrationSessionCaller, declared: string): OrchestrationError { + return new OrchestrationError( + 'consumer_fenced', + `This caller is agent session ${caller.sessionId} and cannot act as ${declared}. No effects were applied.`, + NO_EFFECTS + ) +} + +function hostBoundary(reason: string): OrchestrationError { + return new OrchestrationError( + CODES.hostBoundary, + `${reason} Run the command on that host. No effects were applied.`, + NO_EFFECTS + ) +} diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 6eddcb748be..51ec90d4875 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -15,6 +15,10 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { routeDispatcherClientHostedBrowserRpc } from './dispatcher-client-browser-routing' import { needsLocalCallerFingerprint } from './dispatcher-caller-fingerprint' import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming-feature-emitter' +import { + resolveOrchestrationSessionCaller, + type ResolvedOrchestrationRequest +} from './orchestration-session-caller' export type RpcStreamingDispatcherDependencies = { runtime: OrcaRuntimeService @@ -29,10 +33,11 @@ export class RpcStreamingDispatcher { // Why: streaming dispatch sends multiple responses through the reply callback instead of a Promise. async dispatch( - request: RpcRequest, + rawRequest: RpcRequest, reply: (response: string) => void, options?: RpcDispatchStreamingOptions ): Promise { + let request = rawRequest const { runtime, registry, orchestrationMutations, legacyOrchestration, meta } = this.dependencies const envelopeMeta = meta() @@ -62,13 +67,29 @@ export class RpcStreamingDispatcher { reply(JSON.stringify(parsedParams.error)) return } + // Why: before the unary/streaming split, so both branches see the same resolved caller. + let resolved: ResolvedOrchestrationRequest + try { + resolved = await resolveOrchestrationSessionCaller( + runtime, + request, + parsedParams.value, + options + ) + } catch (error) { + reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) + return + } + request = resolved.request + const params = resolved.params + const orchestrationCaller = resolved.caller if (!isStreamingMethod(method)) { try { const clientHostedBrowser = await routeDispatcherClientHostedBrowserRpc( runtime, request.method, - parsedParams.value + params ) if (clientHostedBrowser.handled) { recordRuntimeFeatureInteraction( @@ -83,16 +104,12 @@ export class RpcStreamingDispatcher { ) return } - const compatibility = await legacyOrchestration.tryHandle( - request, - parsedParams.value, - options?.signal - ) + const compatibility = await legacyOrchestration.tryHandle(request, params, options?.signal) if (compatibility.handled) { reply(JSON.stringify(successResponse(request.id, envelopeMeta, compatibility.result))) return } - const effectiveParams = compatibility.params ?? parsedParams.value + const effectiveParams = compatibility.params ?? params const legacyCoordinator = legacyOrchestration.createCoordinatorInvocation( request, compatibility.legacyCoordinatorAuthority @@ -130,14 +147,16 @@ export class RpcStreamingDispatcher { revalidateLegacyCoordinator: legacyCoordinator?.revalidate, orchestrationCompatibilityCallerAuthority: compatibility.orchestrationCompatibilityCallerAuthority, - orchestrationCompatibilityEvidence: request.orchestrationCompatibilityEvidence + orchestrationCompatibilityEvidence: request.orchestrationCompatibilityEvidence, + orchestrationCaller }) } const result = await orchestrationMutations.run( request, effectiveParams, invoke, - legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint + legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint, + orchestrationCaller?.actor ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) reply(JSON.stringify(successResponse(request.id, envelopeMeta, result))) @@ -156,7 +175,7 @@ export class RpcStreamingDispatcher { try { const result = await method.handler( - parsedParams.value, + params, { runtime, signal: options?.signal, @@ -171,7 +190,8 @@ export class RpcStreamingDispatcher { pairing: options?.pairing, sendBinary: options?.sendBinary, registerBinaryStreamHandler: options?.registerBinaryStreamHandler, - registerBinaryMessageHandler: options?.registerBinaryMessageHandler + registerBinaryMessageHandler: options?.registerBinaryMessageHandler, + orchestrationCaller }, emit ) diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 2dbe9dabd0d..1b4a464cb24 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -14,6 +14,7 @@ import type { OrchestrationDb } from './orchestration/db' import { isStructuredWorkerHandle, structuredWorkerIdentities, + structuredWorkerProcessIncarnation, structuredWorkerRecordIsCurrent, type StructuredWorkerIdentity } from './structured-worker-identity' @@ -47,6 +48,21 @@ export function resolveStructuredWorkerIdentity( return row ? structuredWorkerIdentities.rehydrate(row) : null } +/** The worker identity minted for a session, if that session is a structured worker. */ +export function resolveStructuredWorkerIdentityForSession( + sessionId: string, + db: OrchestrationDb | null | undefined +): StructuredWorkerIdentity | null { + const known = structuredWorkerIdentities.getBySessionId(sessionId) + if (known) { + return known + } + const row = db?.getWorkerTerminalResourceByProcessIncarnation?.( + structuredWorkerProcessIncarnation(sessionId) + ) + return row ? structuredWorkerIdentities.rehydrate(row) : null +} + /** Identity plus a record that still proves this runtime owns the session. */ export function resolveStructuredWorkerAuthority( handle: string, diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index ef2fec6a8fa..1bb0fcfa823 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -25,6 +25,10 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { + formatOrchestrationActor, + sessionOrchestrationActor +} from '../../shared/orchestration-actor' import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope @@ -122,6 +126,15 @@ export function sessionIdFromStructuredWorkerIncarnation( return sessionId.length > 0 ? sessionId : null } +/** The orchestration actor a `structured:` incarnation names; null for any other. */ +export function structuredWorkerActorForIncarnation( + processIncarnation: string | null | undefined +): string | null { + const sessionId = sessionIdFromStructuredWorkerIncarnation(processIncarnation) + const actor = sessionId ? sessionOrchestrationActor(sessionId) : null + return actor ? formatOrchestrationActor(actor) : null +} + /** Structured sessions can only exist local and outside WSL; anything else is not our authority. */ export function structuredWorkerHostScope( location: AgentSessionExecutionLocation diff --git a/src/shared/orchestration-compatibility-evidence.ts b/src/shared/orchestration-compatibility-evidence.ts index 72c7a03c20c..14beca112b7 100644 --- a/src/shared/orchestration-compatibility-evidence.ts +++ b/src/shared/orchestration-compatibility-evidence.ts @@ -26,6 +26,11 @@ export type OrchestrationCompatibilityEvidence = { paneKey?: string launchToken?: string host?: OrchestrationCompatibilityHostStamp + /** + * The Orca-minted agent session id from the caller's injected environment. When present it is + * the caller's identity: the dispatch entry resolves it, and a declared handle must name it. + */ + agentSessionId?: string } const SECRET_KEYS = new Set([ diff --git a/src/shared/orchestration-session-caller-codes.ts b/src/shared/orchestration-session-caller-codes.ts new file mode 100644 index 00000000000..9898250c3a2 --- /dev/null +++ b/src/shared/orchestration-session-caller-codes.ts @@ -0,0 +1,14 @@ +/** + * Refusals for an orchestration request whose caller names itself by its Orca agent session id. + * Each is issued at the dispatch entry, before any method runs, and applies no effects. + */ +export const ORCHESTRATION_SESSION_CALLER_ERROR_CODES = { + /** The request crossed a host boundary (a paired client, SSH, or WSL); session identity is same-host only. */ + hostBoundary: 'session_caller_host_boundary', + /** No Orca agent session with that id exists on this host. */ + unknown: 'session_caller_unknown', + /** The id is a provider's own session id, which rotates on `/clear`; the refusal names the Orca id. */ + providerId: 'session_caller_provider_id', + /** The session exists but has no live owner here: released, switching owners, or unreconciled. */ + notLive: 'session_caller_not_live' +} as const From f9ef59929691d3fa683bcea8b336c96360811cb3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:16:59 -0700 Subject: [PATCH 02/15] test(orchestration): pin actor-keyed Run binding, stale-actor precedence and actor mail ownership Keeps the non-session dispatch path synchronous so terminal and session-tab streams reach their handler without an extra async hop. --- .../run-coordinator-actor-binding.test.ts | 370 ++++++++++++++++++ ...tured-worker-orca-session-backfill.test.ts | 2 + ...tion-orca-session-column-migration.test.ts | 26 +- ...structured-worker-group-addressing.test.ts | 2 + src/main/runtime/rpc/dispatcher.ts | 23 +- .../rpc/orchestration-session-caller.ts | 15 +- .../runtime/rpc/rpc-streaming-dispatcher.ts | 25 +- 7 files changed, 438 insertions(+), 25 deletions(-) create mode 100644 src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts new file mode 100644 index 00000000000..a0de26cf646 --- /dev/null +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts @@ -0,0 +1,370 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../../../structured-worker-identity' +import { OrchestrationDb } from '../../db' + +const CHAT_X = 'session:1b6f0c3a-7d2e-4a91-8c55-2e9d4b7a0f13' +const CHAT_Y = 'session:6d2a9e41-0c7b-4f38-9a15-b3e8c1d57f20' +const WORKER_SESSION = '9c3e5a17-4b2d-4f60-8e91-0d7a6c2b5e48' +const WORKER_ACTOR = `session:${WORKER_SESSION}` +const PTY_PANE = 'tab_pty:11111111-1111-4111-8111-111111111111' +const OTHER_PANE = 'tab_other:22222222-2222-4222-8222-222222222222' +const UNCAPPED = Number.MAX_SAFE_INTEGER + +function chat(actor: string) { + return { terminalHandle: null, paneKey: null, actor } +} + +describe('Run binding by orchestration actor', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + function createChatRun(actor: string, objective = 'chat run') { + return db.createRun({ + objective, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: actor + }) + } + + function directMail(runId: string, to: string, subject = 'direct') { + return db.insertMessage({ from: 'term_sender', to, subject, body: '', runId }) + } + + function structuredWorker() { + return { + terminalHandle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + actor: WORKER_ACTOR + } + } + + it('binds a handle-less session by its actor and remembers the actor as its address', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_actor: CHAT_X + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X))?.id).toBe(run.id) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_X)).toEqual([run.id]) + // Mail to the session's address reaches the Run mailbox, as mail to a coordinator handle does. + expect(directMail(run.id, CHAT_X).to_handle).toBe(`run:${run.id}`) + }) + + it("never unbinds another session's Run, and unbinds only the same session's other Runs", () => { + db = new OrchestrationDb(':memory:') + const ptyRun = db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_pty', + coordinatorPaneKey: PTY_PANE + }) + const yRun = createChatRun(CHAT_Y, 'y') + const xFirst = createChatRun(CHAT_X, 'x first') + const pending = db.insertMessage({ + from: 'term_sender', + to: 'term_late', + subject: 'queued before the rebind', + body: '', + runId: xFirst.id + }) + // Mail addressed to the session that the cache did not reroute on insert, as a pre-cache row. + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(CHAT_X, pending.id) + const generation = db.getRunRaw(xFirst.id)?.consumer_generation ?? 0 + + const xSecond = createChatRun(CHAT_X, 'x second') + + expect(db.getCurrentRunForCoordinator(chat(CHAT_X))?.id).toBe(xSecond.id) + expect(db.getCurrentRunForCoordinator(chat(CHAT_Y))?.id).toBe(yRun.id) + expect(db.getRunRaw(yRun.id)?.coordinator_actor).toBe(CHAT_Y) + expect(db.getRunRaw(ptyRun.id)?.coordinator_pane_key).toBe(PTY_PANE) + expect(db.getRunRaw(xFirst.id)).toMatchObject({ + coordinator_handle: null, + coordinator_actor: null, + consumer_generation: generation + 1 + }) + // Pending coordinator mail follows the Run, as it does when a pane is unbound. + expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${xFirst.id}`) + }) + + it('reads an actor beside a handle it does not bind with as stale, and the handle wins', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X) + // What a binary without the actor column leaves when it rebinds the Run to a terminal. + db.db + .prepare('UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ? WHERE id = ?') + .run('term_taker', PTY_PANE, run.id) + + expect(db.getCurrentRunForCoordinator(chat(CHAT_X))).toBeUndefined() + expect( + db.getCurrentRunForCoordinator({ + terminalHandle: 'term_taker', + paneKey: PTY_PANE, + actor: null + })?.id + ).toBe(run.id) + createChatRun(CHAT_X, 'next') + expect(db.getRunRaw(run.id)?.coordinator_handle).toBe('term_taker') + }) + + it("reads a structured worker's actor as stale once its handle is gone from the Run", () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorActor: worker.actor + }) + expect(db.getCurrentRunForCoordinator(worker)?.id).toBe(run.id) + // An older binary's pane unbind clears handle and pane but not the actor. + db.db + .prepare( + 'UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL WHERE id = ?' + ) + .run(run.id) + expect(db.getCurrentRunForCoordinator(worker)).toBeUndefined() + }) + + it('remembers a coordinating structured worker at its handle and its session address', () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorActor: worker.actor + }) + + expect(db.getRunMailboxOwnerIdsForHandle(worker.terminalHandle)).toEqual([run.id]) + expect(db.getRunMailboxOwnerIdsForHandle(WORKER_ACTOR)).toEqual([run.id]) + expect(directMail(run.id, WORKER_ACTOR).to_handle).toBe(`run:${run.id}`) + }) + + it('hands a Run to a different session like a terminal takeover: fenced, rerouted, remembered', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X) + const pending = directMail(run.id, 'term_late') + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(CHAT_X, pending.id) + const before = db.getRunRaw(run.id)?.consumer_generation ?? 0 + + db.bindRun({ + runId: run.id, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: CHAT_Y + }) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_actor: CHAT_Y, + consumer_generation: before + 1 + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X))).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(CHAT_Y))?.id).toBe(run.id) + expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${run.id}`) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_Y)).toEqual([run.id]) + }) + + it('rebinding the same session is not a new consumer, and fills a missing actor in place', () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey + }) + const before = db.getRunRaw(run.id)?.consumer_generation + + db.bindRun({ + runId: run.id, + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorActor: worker.actor + }) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_actor: WORKER_ACTOR, + consumer_generation: before + }) + }) +}) + +describe('mail owned by an active Dispatch assignee addressed by its actor', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + /** A structured worker that coordinates its own Run and is also an active assignee in it. */ + function workerCoordinatingItsOwnDispatch() { + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(WORKER_SESSION) + const run = db.createRun({ + objective: 'nested', + coordinatorHandle: handle, + coordinatorPaneKey: paneKey, + coordinatorActor: WORKER_ACTOR + }) + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'own work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: { kind: 'system' }, + maxDepth: UNCAPPED + }) + return { run, dispatch, handle } + } + + it('keeps mail to the session address out of the Run mailbox, as it does for the handle', () => { + db = new OrchestrationDb(':memory:') + const { run } = workerCoordinatingItsOwnDispatch() + + expect( + db.insertMessage({ from: 'term_x', to: WORKER_ACTOR, subject: 's', body: '', runId: run.id }) + .to_handle + ).toBe(WORKER_ACTOR) + }) + + it('leaves that mail in place when the Run is rebound', () => { + db = new OrchestrationDb(':memory:') + const { run } = workerCoordinatingItsOwnDispatch() + const mail = db.insertMessage({ + from: 'term_x', + to: WORKER_ACTOR, + subject: 's', + body: '', + runId: run.id + }) + + db.routeAllUnreadDirectMessagesToRunMailbox(run.id, WORKER_ACTOR) + + expect(db.getMessageById(mail.id)?.to_handle).toBe(WORKER_ACTOR) + }) + + it("sweeps the session's stray mail from another Run into its Dispatch mailbox", () => { + db = new OrchestrationDb(':memory:') + const { run, dispatch } = workerCoordinatingItsOwnDispatch() + db.createRun({ + objective: 'elsewhere', + coordinatorHandle: 'term_c', + coordinatorPaneKey: OTHER_PANE + }) + const stray = db.insertMessage({ + from: 'term_x', + to: WORKER_ACTOR, + subject: 's', + body: '', + runId: run.id + }) + + const routed = db.routeForeignDirectMessagesToOwnedMailboxes(WORKER_ACTOR) + + expect(routed.routedCount).toBe(1) + expect(db.getMessageById(stray.id)?.to_handle).toBe(`dispatch:${dispatch.id}`) + }) +}) + +describe('Dispatch actors recorded by every writer', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('records the assignee actor from a structured incarnation and a creator actor', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: CHAT_X + }) + const handle = mintStructuredWorkerHandle() + const assigned = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'structured' }).id, + assigneeHandle: handle, + assigneePaneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: { kind: 'actor', actor: CHAT_X }, + maxDepth: UNCAPPED + }) + const pty = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'pty' }).id, + assigneeHandle: 'term_pty', + assigneePaneKey: PTY_PANE, + processIncarnation: 'pty_proc:1', + creator: { kind: 'terminal', handle: 'term_c', paneKey: OTHER_PANE }, + maxDepth: UNCAPPED + }) + + expect(assigned).toMatchObject({ + assignee_actor: WORKER_ACTOR, + creator_handle: null, + creator_pane_key: null, + creator_actor: CHAT_X + }) + expect(pty).toMatchObject({ assignee_actor: null, creator_actor: null }) + }) + + it('records the starting creator and the attached assignee of a worker-start', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: CHAT_X + }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'actor', actor: CHAT_X }, + maxDepth: UNCAPPED, + taskSpec: 'work', + taskRunId: run.id, + startOptions: {} + }) + expect(started.dispatch).toMatchObject({ creator_actor: CHAT_X, assignee_actor: null }) + + const handle = mintStructuredWorkerHandle() + db.prepareStartingWorkerAuthority({ + dispatchId: started.dispatch.id, + handle, + paneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + worktreeId: 'wt_1', + effects: [], + setupState: 'not_applicable' + }) + + expect(db.getDispatchContextById(started.dispatch.id)?.assignee_actor).toBe(WORKER_ACTOR) + }) + + it('counts a Dispatch whose creator and assignee are the same actor as bookkeeping', () => { + db = new OrchestrationDb(':memory:') + const worker = { + kind: 'terminal', + handle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + actor: WORKER_ACTOR + } as const + db.createDispatchContext({ + taskId: db.createTask({ runId: 'run_legacy_local', spec: 'own' }).id, + assigneeHandle: worker.handle, + assigneePaneKey: 'tab_elsewhere:33333333-3333-4333-8333-333333333333', + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: worker, + maxDepth: UNCAPPED + }) + + expect(db.resolveCreatorDepth(worker)).toBe(0) + }) +}) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts index 57bf53e7026..d31f5cc241f 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts @@ -96,6 +96,8 @@ describe('structured worker Orca session id backfill', () => { processIncarnation: structuredWorkerProcessIncarnation(SESSION_B), ownership: 'owned' }) + // Rows a writer without the actor column left; a current writer records the incarnation's actor. + db.db.exec('UPDATE dispatch_contexts SET assignee_actor = NULL, creator_actor = NULL') backfillStructuredWorkerOrcaSessionIds(db.db) diff --git a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts index 69186334828..e9fb9ae9b39 100644 --- a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts @@ -49,6 +49,25 @@ const HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL = ` VALUES (NEW.id, NEW.coordinator_handle); END;` +// The mail routing trigger as main recreated it on every open at v41 and before: handle-only. +const HANDLE_ONLY_MAIL_ROUTING_TRIGGER_SQL = ` + CREATE TRIGGER trg_messages_route_coordinator_mail + AFTER INSERT ON messages + WHEN NEW.read = 0 AND NEW.delivery_contract = 'current_delivery' + AND EXISTS (SELECT 1 FROM runs WHERE runs.id = NEW.run_id AND runs.legacy = 0) + AND EXISTS ( + SELECT 1 FROM run_coordinator_handles + WHERE run_id = NEW.run_id AND terminal_handle = NEW.to_handle + ) + AND NOT EXISTS ( + SELECT 1 FROM dispatch_contexts + WHERE run_id = NEW.run_id AND assignee_handle = NEW.to_handle + AND status IN ('pending', 'dispatched') + ) + BEGIN + UPDATE messages SET to_handle = 'run:' || NEW.run_id WHERE sequence = NEW.sequence; + END;` + const V41_RUN_COLUMNS = 'id, objective, home_database, coordinator_handle, coordinator_pane_key, consumer_generation, legacy, created_at, updated_at' @@ -117,11 +136,13 @@ function stripOrcaSessionSchema(path: string, version: number): void { DROP INDEX idx_dispatch_assignee_orca_session_id; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; + DROP TRIGGER trg_messages_route_coordinator_mail; ALTER TABLE runs DROP COLUMN coordinator_orca_session_id; ALTER TABLE runs DROP COLUMN coordinator_orca_session_id_generation; ALTER TABLE dispatch_contexts DROP COLUMN assignee_orca_session_id; ALTER TABLE dispatch_contexts DROP COLUMN creator_orca_session_id; ${HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL} + ${HANDLE_ONLY_MAIL_ROUTING_TRIGGER_SQL} `) raw.pragma(`user_version = ${version}`) raw.close() @@ -419,8 +440,11 @@ describe('orchestration Orca session id column migration', () => { it('fills structured-worker rows written after the stamp reached v42 on the next open', () => { const path = tempDbPath() const first = new OrchestrationDb(path) - // No writer records an Orca session id yet, which is also the shape a binary rolled back past v42 writes. const rows = seedStructuredAndPtyRows(first) + // The shape a binary rolled back past v42 writes: its INSERTs name no Orca session id column. + first.db.exec( + 'UPDATE dispatch_contexts SET assignee_orca_session_id = NULL, creator_orca_session_id = NULL; UPDATE runs SET coordinator_orca_session_id = NULL' + ) expect( first.getDispatchContextById(rows.structuredDispatchId)?.assignee_orca_session_id ).toBeNull() diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index 93fa58fd6b5..b6d89567fbd 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -227,6 +227,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { const db = { getLegacyAdoptedRunMailboxOwner: () => null, getCurrentRunForPane: () => undefined, + getCurrentRunForCoordinator: () => undefined, getActiveDispatchForIdentity: () => ({ run_id: 'run_1' }), getActiveDispatchMailboxOwners: () => [], getRunMailboxOwnerIdsForHandle: () => [], @@ -268,6 +269,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { db: db as never, from: 'term_sender', groupAddress: '@codex', + sender: { address: 'term_sender', terminalHandle: 'term_sender', paneKey: null, actor: null }, senderPaneKey: undefined, senderRunId: 'run_1', explicitRunId: undefined, diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index fd28620892e..f541bb073e2 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -24,6 +24,7 @@ import { parseRpcRequestParams } from './dispatcher-request-parsing' import { RpcStreamingDispatcher } from './rpc-streaming-dispatcher' import { invokeDispatcherUnaryMethod } from './dispatcher-unary-method-invocation' import { + claimsOrchestrationSession, resolveOrchestrationSessionCaller, type ResolvedOrchestrationRequest } from './orchestration-session-caller' @@ -77,16 +78,18 @@ export class RpcDispatcher { if (parsedParams.error) { return parsedParams.error } - let resolved: ResolvedOrchestrationRequest - try { - resolved = await resolveOrchestrationSessionCaller( - this.runtime, - request, - parsedParams.value, - options - ) - } catch (error) { - return mapDispatcherError(request, meta, error) + let resolved: ResolvedOrchestrationRequest = { request, params: parsedParams.value } + if (claimsOrchestrationSession(request)) { + try { + resolved = await resolveOrchestrationSessionCaller( + this.runtime, + request, + resolved.params, + options + ) + } catch (error) { + return mapDispatcherError(request, meta, error) + } } if (isStreamingMethod(method)) { diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index 466f1408bf5..3dcde663c29 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -66,6 +66,18 @@ export type ResolvedOrchestrationRequest = { const NO_EFFECTS = { effectsApplied: false } as const +/** + * Whether this request names its caller by a session id. Checked synchronously so every other + * request, terminal callers included, reaches its method without an extra async hop. + */ +export function claimsOrchestrationSession(request: RpcRequest): boolean { + return ( + request.method.startsWith('orchestration.') && + request.orchestrationCompatibilityEvidence?.agentSessionId !== undefined + ) +} + +/** Only for a request `claimsOrchestrationSession` accepts. Throws the refusal, if any. */ export async function resolveOrchestrationSessionCaller( runtime: OrcaRuntimeService, request: RpcRequest, @@ -74,9 +86,6 @@ export async function resolveOrchestrationSessionCaller( ): Promise { const evidence = request.orchestrationCompatibilityEvidence const claimed: unknown = evidence?.agentSessionId - if (!request.method.startsWith('orchestration.') || claimed === undefined) { - return { request, params } - } if (route?.pairedDeviceId !== undefined) { throw hostBoundary( 'This request reached Orca from a paired client, and an agent session id identifies a caller only on the host that runs that session.' diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 51ec90d4875..d4a99f54ad5 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -16,6 +16,7 @@ import { routeDispatcherClientHostedBrowserRpc } from './dispatcher-client-brows import { needsLocalCallerFingerprint } from './dispatcher-caller-fingerprint' import { createDispatcherStreamingFeatureEmitter } from './dispatcher-streaming-feature-emitter' import { + claimsOrchestrationSession, resolveOrchestrationSessionCaller, type ResolvedOrchestrationRequest } from './orchestration-session-caller' @@ -68,17 +69,19 @@ export class RpcStreamingDispatcher { return } // Why: before the unary/streaming split, so both branches see the same resolved caller. - let resolved: ResolvedOrchestrationRequest - try { - resolved = await resolveOrchestrationSessionCaller( - runtime, - request, - parsedParams.value, - options - ) - } catch (error) { - reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) - return + let resolved: ResolvedOrchestrationRequest = { request, params: parsedParams.value } + if (claimsOrchestrationSession(request)) { + try { + resolved = await resolveOrchestrationSessionCaller( + runtime, + request, + resolved.params, + options + ) + } catch (error) { + reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) + return + } } request = resolved.request const params = resolved.params From 5dc2fc1f95fabd027535460dd169b531c816c64d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:43:01 -0700 Subject: [PATCH 03/15] feat(orchestration): resolve session callers before params parse and pin every verb on both routes A session caller need not name itself in a param that requires a caller: the entry binds the declared caller to the session before the schema runs. Session refusal codes pass through the RPC error map, and DB row reads added here carry their SAFETY rationale. --- .../orchestration/db/dispatch-depth.ts | 22 +- .../foreign-direct-mailbox-routing.ts | 5 +- .../orchestration/db/runs/run-lookup.ts | 6 +- .../worker-terminal-resource-store.ts | 6 +- ...structured-worker-group-addressing.test.ts | 1 + src/main/runtime/rpc/dispatcher.ts | 19 +- src/main/runtime/rpc/errors.ts | 4 +- ...chestration-session-caller-test-fixture.ts | 165 ++++++++ .../rpc/orchestration-session-caller.test.ts | 353 ++++++++++++++++ .../rpc/orchestration-session-caller.ts | 17 +- .../orchestration-session-coordinator.test.ts | 400 ++++++++++++++++++ .../runtime/rpc/rpc-streaming-dispatcher.ts | 23 +- 12 files changed, 969 insertions(+), 52 deletions(-) create mode 100644 src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts create mode 100644 src/main/runtime/rpc/orchestration-session-caller.test.ts create mode 100644 src/main/runtime/rpc/orchestration-session-coordinator.test.ts diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index 2ba59b53e67..749989f1eb4 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -140,18 +140,18 @@ function findActiveDispatchForCreator( this: OrchestrationDb, creator: Exclude ): DispatchContextRow | undefined { - if (creator.kind === 'actor') { - return this.db - .prepare( - `SELECT * FROM dispatch_contexts - WHERE assignee_actor = ? AND status IN ('pending', 'dispatched') - ORDER BY rowid DESC LIMIT 1` - ) - .get(creator.actor) as DispatchContextRow | undefined + if (creator.kind === 'terminal') { + return this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) } - return this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) as - | DispatchContextRow - | undefined + const row = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE assignee_actor = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .get(creator.actor) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return row as DispatchContextRow | undefined } /** diff --git a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts index 2a6fd887729..1bf45236d60 100644 --- a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts @@ -32,9 +32,10 @@ export function findActiveDispatchForDirectMessageOwner( WHERE run_id = ? AND assignee_actor = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) - .get(runId, directHandle) as DispatchContextRow | undefined + .get(runId, directHandle) if (byActor) { - return byActor + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return byActor as DispatchContextRow } } if (!paneKey || !parsePaneKey(paneKey)) { diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index b07ed1c0f3b..d8e0ca25505 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -150,9 +150,9 @@ export function runsBoundToCoordinator( return caller.paneKey === null ? [] : this.runsBoundToPane(caller.paneKey) } const suffix = caller.paneKey === null ? null : paneKeyMatchSuffix(caller.paneKey) - return ( - this.db.prepare(RUNS_BOUND_TO_COORDINATOR_SQL).all(suffix, caller.actor) as RunRow[] - ).filter((run) => runBoundToCoordinator(run, caller)) + const rows = this.db.prepare(RUNS_BOUND_TO_COORDINATOR_SQL).all(suffix, caller.actor) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return (rows as RunRow[]).filter((run) => runBoundToCoordinator(run, caller)) } export function unbindOtherRunsForCoordinator( diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index 26e5b2977c2..fe26380d539 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -127,12 +127,14 @@ export function getWorkerTerminalResourceByProcessIncarnation( this: OrchestrationDb, processIncarnation: string ): WorkerTerminalResourceRow | undefined { - return this.db + const row = this.db .prepare( `SELECT * FROM worker_terminal_resources WHERE process_incarnation = ? ORDER BY updated_at DESC LIMIT 1` ) - .get(processIncarnation) as WorkerTerminalResourceRow | undefined + .get(processIncarnation) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. + return row as WorkerTerminalResourceRow | undefined } export function getWorkerTerminalResourceFormerlyOwnedBy( diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index b6d89567fbd..2c091318b5b 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -263,6 +263,7 @@ describe('sendGroupMessage actually composes structured workers in', () => { getOrchestrationDb: () => db, notifyMessageArrived: () => {} } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stub cast is unchanged; the gate flags it only because this diff adds the sender field inside its span. await sendGroupMessage({ params: { subject: 's', body: 'b', type: 'status', priority: 'normal' }, runtime: runtime as never, diff --git a/src/main/runtime/rpc/dispatcher.ts b/src/main/runtime/rpc/dispatcher.ts index f541bb073e2..51c64fdd383 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -74,23 +74,18 @@ export class RpcDispatcher { return migrationFence } - const parsedParams = parseRpcRequestParams(request, method, meta) - if (parsedParams.error) { - return parsedParams.error - } - let resolved: ResolvedOrchestrationRequest = { request, params: parsedParams.value } + let resolved: ResolvedOrchestrationRequest = { request } if (claimsOrchestrationSession(request)) { try { - resolved = await resolveOrchestrationSessionCaller( - this.runtime, - request, - resolved.params, - options - ) + resolved = await resolveOrchestrationSessionCaller(this.runtime, request, options) } catch (error) { return mapDispatcherError(request, meta, error) } } + const parsedParams = parseRpcRequestParams(resolved.request, method, meta) + if (parsedParams.error) { + return parsedParams.error + } if (isStreamingMethod(method)) { return errorResponse( @@ -109,7 +104,7 @@ export class RpcDispatcher { runtime: this.runtime, request: resolved.request, method, - params: resolved.params, + params: parsedParams.value, context: { runtime: this.runtime, signal: options?.signal, diff --git a/src/main/runtime/rpc/errors.ts b/src/main/runtime/rpc/errors.ts index c2402d87849..ec7f7a0559d 100644 --- a/src/main/runtime/rpc/errors.ts +++ b/src/main/runtime/rpc/errors.ts @@ -3,6 +3,7 @@ // format human-facing messages. Centralizing this mapping keeps the allowlist // auditable in one place instead of spread across per-method branches. import type { RpcEnvelopeMeta, RpcFailure, RpcSuccess } from './core' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES } from '../../../shared/orchestration-session-caller-codes' import { computerUseErrorRecoveryData } from '../../../shared/computer-use-error-recovery' import { COMPUTER_ERROR_CODES } from '../../../shared/runtime-types' import { LINEAR_ERROR_CODES } from '../../../shared/linear/agent-access' @@ -153,7 +154,8 @@ const STRUCTURED_RUNTIME_PASSTHROUGH_CODES: ReadonlySet = new Set([ SKILL_INSTALL_RPC_ERROR_CODE, // Why: an owner conflict is a distinct client decision (reload the host, re-adopt, // stop offering the action) — flattened to runtime_error it can only be guessed at. - ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES) + ...Object.values(AUTOMATION_OWNER_CONFLICT_CODES), + ...Object.values(ORCHESTRATION_SESSION_CALLER_ERROR_CODES) ]) export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknown): RpcFailure { diff --git a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts new file mode 100644 index 00000000000..71996185d89 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts @@ -0,0 +1,165 @@ +import { vi } from 'vitest' +import type { AgentSessionLease, AgentSessionRecord } from '../../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../../shared/agent-session-record.test-fixture' +import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import { OrcaRuntimeService } from '../orca-runtime' +import { OrchestrationDb } from '../orchestration/db' +import { structuredWorkerIdentities } from '../structured-worker-identity' +import type { RpcRequest, RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { ORCHESTRATION_METHODS } from './methods/orchestration' + +export const SESSION_X = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' +export const SESSION_Y = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' +export const ACTOR_X = `session:${SESSION_X}` +export const ACTOR_Y = `session:${SESSION_Y}` +export const PROVIDER_ID_X = 'c0ffee11-2233-4455-8677-8899aabbccdd' +export const WORKSPACE_X = 'repo_1::/work/tree-x' +export const WORKER_HANDLE = 'term_worker' +export const WORKER_PANE = 'tab_worker:77777777-7777-4777-8777-777777777777' + +export type SessionHostRef = { current: unknown } + +/** A live chat session record; the lease and location can be pushed into any state. */ +export function sessionRecord( + sessionId: string, + overrides: { + lease?: Partial + location?: Partial + providerId?: string + } = {} +): AgentSessionRecord { + const base = agentSessionRecordFixture( + agentSessionLeaseFixture({ sessionId, runtimeKind: 'native', ...overrides.lease }) + ) + return { + ...base, + location: { ...base.location, workspaceId: WORKSPACE_X, ...overrides.location }, + providerHandleChain: base.providerHandleChain.map((link) => ({ + ...link, + handle: { + provider: 'claude' as const, + sessionId: overrides.providerId ?? `provider-${sessionId}`, + leafUuid: null + } + })) + } +} + +export type SessionCallerHarness = { + runtime: OrcaRuntimeService + db: OrchestrationDb + dispatcher: RpcDispatcher + records: Map + /** Unary socket route: what the local CLI's Unix socket and Electron IPC reach. */ + dispatch: (request: RpcRequest) => Promise + /** Streaming dispatcher, optionally as a paired client on another host. */ + dispatchStreaming: (request: RpcRequest, pairedDeviceId?: string) => Promise + close: () => void +} + +export function createSessionCallerHarness(hostRef: SessionHostRef): SessionCallerHarness { + const db = new OrchestrationDb(':memory:') + const runtime = new OrcaRuntimeService() + runtime.setOrchestrationDb(db) + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue() + vi.spyOn(runtime, 'getTerminalPaneKey').mockImplementation((handle) => + handle === WORKER_HANDLE ? WORKER_PANE : null + ) + vi.spyOn(runtime, 'getLiveTerminalPaneKey').mockImplementation((handle) => + runtime.getTerminalPaneKey(handle) + ) + const records = new Map([ + [SESSION_X, sessionRecord(SESSION_X, { providerId: PROVIDER_ID_X })], + [SESSION_Y, sessionRecord(SESSION_Y)] + ]) + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => records.get(sessionId) ?? null, + listRecords: () => [...records.values()] + } + } + } + structuredWorkerIdentities.clear() + const dispatcher = new RpcDispatcher({ runtime, methods: ORCHESTRATION_METHODS }) + return { + runtime, + db, + dispatcher, + records, + dispatch: (request) => dispatcher.dispatch(request), + dispatchStreaming: async (request, pairedDeviceId) => { + const replies: string[] = [] + await dispatcher.dispatchStreaming( + request, + (reply) => replies.push(reply), + pairedDeviceId ? { pairedDeviceId } : {} + ) + const [reply] = replies + if (replies.length !== 1 || reply === undefined) { + throw new Error(`expected exactly one reply, got ${replies.length}`) + } + const parsed: unknown = JSON.parse(reply) + return parsed + }, + close: () => { + hostRef.current = null + structuredWorkerIdentities.clear() + db.close() + } + } +} + +let requestCounter = 0 + +/** An orchestration request as the CLI sends it, naming its caller by session id when given. */ +export function orchestrationRequest( + method: string, + params: Record, + options: { + sessionId?: string + requestId?: string + evidence?: OrchestrationCompatibilityEvidence + } = {} +): RpcRequest { + requestCounter += 1 + const requestId = options.requestId ?? `req-${requestCounter}` + const evidence = + options.sessionId === undefined + ? options.evidence + : { ...options.evidence, agentSessionId: options.sessionId } + return { + id: `rpc-${requestCounter}`, + authToken: 'test', + method, + params, + orchestrationContractVersion: ORCHESTRATION_CONTRACT_VERSION, + orchestrationRequestId: requestId, + compatibilityInvocationId: requestId, + ...(evidence ? { orchestrationCompatibilityEvidence: evidence } : {}) + } +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function resultOf(response: unknown): Record { + if (!isRecord(response) || response.ok !== true || !isRecord(response.result)) { + throw new Error(`expected a successful response, got ${JSON.stringify(response)}`) + } + return response.result +} + +/** The `id` of a row a receipt carries. */ +export function idOf(row: unknown): string { + if (!isRecord(row) || typeof row.id !== 'string') { + throw new Error(`expected a row with an id, got ${JSON.stringify(row)}`) + } + return row.id +} diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts new file mode 100644 index 00000000000..fdc16336b43 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -0,0 +1,353 @@ +import { mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { ZodObject } from 'zod' +import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' +import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' +import { DeviceRegistry } from '../device-registry' +import { OrcaRuntimeRpcServer } from '../runtime-rpc' +import { buildRegistry } from './core' +import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { + ACTOR_X, + createSessionCallerHarness, + orchestrationRequest, + PROVIDER_ID_X, + idOf, + resultOf, + SESSION_X, + sessionRecord, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' +import { + claimsOrchestrationSession, + ORCHESTRATION_CALLER_PARAM +} from './orchestration-session-caller' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +// Fields that name a party. They name the caller only in the methods ORCHESTRATION_CALLER_PARAM lists. +const CALLER_SHAPED_FIELDS = ['from', 'terminal', 'callerTerminalHandle'] as const +// Methods with such a field that never reads it as the caller's identity, for any actor. +const NAMES_A_PARTY_BUT_NOT_THE_CALLER: Readonly> = { + 'orchestration.run': 'retired; refused before any handler', + 'orchestration.runShow': 'reads a Run by id; `from` is unused', + 'orchestration.dispatchShow': '`from` only fills the preview preamble text', + 'orchestration.inbox': '`terminal` is a read filter over stored mail', + 'orchestration.federationAttachStart': '`terminal` names the remote worker terminal', + 'orchestration.workerTerminalUserInput': '`terminal` names the worker terminal' +} + +/** One request per identity-consulting method, valid enough to reach the dispatcher entry. */ +const MINIMAL_PARAMS: Readonly>> = { + 'orchestration.runCreate': { objective: 'o' }, + 'orchestration.runUse': { id: 'run_missing' }, + 'orchestration.runCurrent': {}, + 'orchestration.check': {}, + 'orchestration.send': { subject: 's', to: 'term_worker' }, + 'orchestration.reply': { id: 'msg_missing', body: 'b' }, + 'orchestration.ask': { question: 'q', to: 'term_worker' }, + 'orchestration.dispatch': { task: 'task_missing', to: 'term_worker' }, + 'orchestration.gateCreate': { task: 'task_missing', question: 'q' }, + 'orchestration.gateResolve': { id: 'gate_missing', resolution: 'r' }, + 'orchestration.gateList': {}, + 'orchestration.taskCreate': { spec: 's' }, + 'orchestration.taskList': {}, + 'orchestration.taskUpdate': { id: 'task_missing', status: 'completed' }, + 'orchestration.workerStart': { spec: 's' } +} + +describe('orchestration session callers at the dispatch entry', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + it('lists exactly the methods whose params name their caller, and classifies every other one', () => { + const registry = buildRegistry(ORCHESTRATION_METHODS) + const callerShaped = [...registry.values()] + .filter((method) => { + const schema = method.params + return ( + schema instanceof ZodObject && + CALLER_SHAPED_FIELDS.some((field) => Object.hasOwn(schema.shape, field)) + ) + }) + .map((method) => method.name) + .sort() + + // The population: 41 registered methods, 21 of which carry a party-naming field. + expect(registry.size).toBe(41) + expect(callerShaped).toHaveLength(21) + expect(callerShaped).toEqual( + [ + ...Object.keys(ORCHESTRATION_CALLER_PARAM), + ...Object.keys(NAMES_A_PARTY_BUT_NOT_THE_CALLER) + ].sort() + ) + for (const [method, field] of Object.entries(ORCHESTRATION_CALLER_PARAM)) { + const schema = registry.get(method)?.params + expect(schema instanceof ZodObject && Object.hasOwn(schema.shape, field), method).toBe(true) + } + expect(Object.keys(MINIMAL_PARAMS).sort()).toEqual( + Object.keys(ORCHESTRATION_CALLER_PARAM).sort() + ) + }) + + it.each(Object.keys(ORCHESTRATION_CALLER_PARAM))( + 'refuses %s from a paired client before any effect, naming the host boundary', + async (method) => { + const response = await h.dispatchStreaming( + orchestrationRequest(method, MINIMAL_PARAMS[method] ?? {}, { sessionId: SESSION_X }), + 'paired-device-1' + ) + + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.hostBoundary, + message: expect.stringContaining('only on the host that runs that session'), + data: { effectsApplied: false } + } + }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toEqual([]) + } + ) + + it.each(Object.keys(ORCHESTRATION_CALLER_PARAM))( + 'resolves %s on the local route as the session, never as a terminal', + async (method) => { + const spy = vi.spyOn(h.runtime, 'verifyOrchestrationCompatibilityCaller') + const response = await h.dispatch( + orchestrationRequest(method, MINIMAL_PARAMS[method] ?? {}, { + sessionId: SESSION_X, + evidence: { terminalHandle: 'term_tui', paneKey: 'tab_tui:1:2', launchToken: 'secret' } + }) + ) + + // Whatever the method answers, it answered as the session: no host-boundary or session refusal, + // and the terminal evidence a terminal view inherits never attested anyone. + if (!response.ok) { + expect(Object.values(CODES)).not.toContain(response.error.code) + expect(response.error.message).not.toContain('term_tui') + } + for (const [evidence] of spy.mock.calls) { + expect(evidence?.terminalHandle).toBeUndefined() + } + } + ) + + it('admits the session on the real Unix-socket route and refuses it on the real paired route', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-session-caller-')) + const server = new OrcaRuntimeRpcServer({ + runtime: h.runtime, + userDataPath, + enableWebSocket: false + }) + server['deviceRegistry'] = new DeviceRegistry(userDataPath) + const device = server['deviceRegistry'].addDevice('laptop', 'runtime') + const request = orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X + } + ) + + const replies: string[] = [] + await server['handleWebSocketMessage']( + JSON.stringify({ ...request, deviceToken: device.token }), + (reply) => replies.push(reply), + () => {}, + undefined, + undefined, + device.token + ) + expect(JSON.parse(replies[0] ?? '{}')).toMatchObject({ + ok: false, + error: { code: CODES.hostBoundary } + }) + + const local = await server['handleMessage']( + JSON.stringify({ ...request, authToken: server['authToken'] }) + ) + expect(local).toMatchObject({ ok: true, result: { run: { objective: 'o' } } }) + expect( + h.db.getCurrentRunForCoordinator({ terminalHandle: null, paneKey: null, actor: ACTOR_X }) + ).toMatchObject({ objective: 'o', coordinator_actor: ACTOR_X }) + }) + + describe('refuses a session that cannot act, before any destructive or consuming lookup', () => { + function seedPendingMail(): { runId: string; messageId: string } { + const run = h.db.createRun({ + objective: 'x', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: ACTOR_X + }) + const message = h.db.insertMessage({ + from: 'term_worker', + to: ACTOR_X, + subject: 'pending', + body: '', + runId: run.id + }) + return { runId: run.id, messageId: message.id } + } + + async function expectRefusedWithNoEffects( + sessionId: string, + code: string, + message: RegExp, + evidence?: OrchestrationCompatibilityEvidence + ): Promise { + const { messageId } = seedPendingMail() + for (const [method, params] of [ + ['orchestration.check', {}], + ['orchestration.reset', { messages: true }] + ] as const) { + const response = await h.dispatch( + orchestrationRequest(method, params, { sessionId, evidence }) + ) + expect(response, method).toMatchObject({ + ok: false, + error: { code, message: expect.stringMatching(message) } + }) + } + expect(h.db.getMessageById(messageId)).toMatchObject({ read: 0 }) + } + + it('an id that names no Orca session', async () => { + await expectRefusedWithNoEffects( + 'ffffffff-0000-4000-8000-000000000000', + CODES.unknown, + /No Orca agent session .* exists on this host/ + ) + }) + + it('a terminal handle presented as a session id', async () => { + await expectRefusedWithNoEffects('term_4f2c9a0b', CODES.unknown, /not an Orca session id/) + }) + + it("a provider's session id, with a hint naming the Orca id", async () => { + const response = await h.dispatch( + orchestrationRequest('orchestration.runCurrent', {}, { sessionId: PROVIDER_ID_X }) + ) + expect(response).toMatchObject({ + ok: false, + error: { + code: CODES.providerId, + message: expect.stringContaining(`This session's Orca id is ${SESSION_X}`), + data: { orcaSessionId: SESSION_X, effectsApplied: false } + } + }) + await expectRefusedWithNoEffects(PROVIDER_ID_X, CODES.providerId, /changes on \/clear/) + }) + + it('a released lease', async () => { + h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { claimStatus: 'released' } })) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /has ended/) + }) + + it('a lease mid-handoff between chat and terminal view', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { lease: { handoffStage: 'preparing', handoffOperationId: 'op' } }) + ) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /switching between chat/) + }) + + it('a lease the host has not reconciled since restart', async () => { + h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { unreconciled: true } })) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /no live owner/) + }) + + it('a session whose record says it runs on another host', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) + ) + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /runs on another host/) + }) + + it('a request from an SSH or WSL environment', async () => { + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /an SSH environment/, { + host: { kind: 'ssh', targetId: 't', connectionIncarnation: 'c', attachmentId: 'a' } + }) + await expectRefusedWithNoEffects(SESSION_X, CODES.hostBoundary, /a WSL environment/, { + host: { kind: 'wsl', hostId: 'local', distro: 'Ubuntu' } + }) + }) + + it('an agent-session host that cannot be brought up to verify it', async () => { + hostRef.current = null + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /cannot be verified/) + }) + }) + + describe('a declared caller must name the session', () => { + it('refuses a declared terminal handle that is not the session', async () => { + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: 'term_sibling' }, + { sessionId: SESSION_X } + ) + ) + expect(response).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + message: `This caller is agent session ${SESSION_X} and cannot act as term_sibling. No effects were applied.` + } + }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toEqual([]) + }) + + it("refuses a caller-supplied pane key on check, the restart fallback's substitute", async () => { + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.check', + { terminalPaneKey: 'tab_worker:77777777-7777-4777-8777-777777777777' }, + { sessionId: SESSION_X } + ) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) + }) + + it.each([ACTOR_X, SESSION_X])('accepts the session named as %s', async (declared) => { + const run = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o', from: declared }, + { sessionId: SESSION_X } + ) + ) + ).run + expect(run).toMatchObject({ objective: 'o', coordinator_handle: null }) + }) + }) + + it('leaves a terminal caller untouched: no claim, no normalization, no extra hop', async () => { + const request = orchestrationRequest('orchestration.runCreate', { + objective: 'o', + from: 'term_worker' + }) + expect(claimsOrchestrationSession(request)).toBe(false) + + const run = resultOf(await h.dispatch(request)).run + expect(run).toMatchObject({ coordinator_handle: 'term_worker' }) + expect(h.db.getRunRaw(idOf(run))?.coordinator_actor).toBeNull() + }) +}) diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index 3dcde663c29..4acf61ba611 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -1,7 +1,8 @@ /** * Resolves an orchestration caller that names itself by the Orca agent session id in its injected - * environment. Both dispatchers call this once, after params parse and before the unary/streaming - * split, so it runs ahead of legacy compatibility, receipt lookup and every method. + * environment. Both dispatchers call this once, before params parse and the unary/streaming split, + * so it runs ahead of legacy compatibility, receipt lookup and every method. Before parsing, so a + * session caller need not name itself in a param that requires a caller. * * The id names the caller; nothing here is a credential. Every agent on this host runs as the same * user, so the checks are about getting the identity right, not about keeping anyone out: @@ -59,8 +60,8 @@ export type OrchestrationRequestRoute = { } export type ResolvedOrchestrationRequest = { + /** The request with its declared caller bound to the session and its evidence reduced to it. */ request: RpcRequest - params: unknown caller?: OrchestrationSessionCaller } @@ -81,7 +82,6 @@ export function claimsOrchestrationSession(request: RpcRequest): boolean { export async function resolveOrchestrationSessionCaller( runtime: OrcaRuntimeService, request: RpcRequest, - params: unknown, route: OrchestrationRequestRoute | undefined ): Promise { const evidence = request.orchestrationCompatibilityEvidence @@ -118,9 +118,12 @@ export async function resolveOrchestrationSessionCaller( workspaceId: record.location.workspaceId }) return { - // Why: the session wins, so terminal evidence inherited from a terminal view never attests. - request: { ...request, orchestrationCompatibilityEvidence: { agentSessionId: sessionId } }, - params: bindDeclaredCaller(request.method, params, caller), + request: { + ...request, + params: bindDeclaredCaller(request.method, request.params, caller), + // Why: the session wins, so terminal evidence inherited from a terminal view never attests. + orchestrationCompatibilityEvidence: { agentSessionId: sessionId } + }, caller } } diff --git a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts new file mode 100644 index 00000000000..1f091730891 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -0,0 +1,400 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ACTOR_X, + ACTOR_Y, + createSessionCallerHarness, + orchestrationRequest, + idOf, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + WORKER_HANDLE, + WORKER_PANE, + WORKSPACE_X, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +type Row = Record + +describe('a structured chat coordinates through the same verbs as a terminal', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await h.dispatch(orchestrationRequest(method, params, { sessionId }))) + } + + async function runCreate(sessionId: string, objective = 'o'): Promise { + const { run } = await as(sessionId, 'orchestration.runCreate', { objective }) + return idOf(run) + } + + it('runs the supervised loop: run, task, dispatch, worker mail in, check, send, reply, gates', async () => { + const runId = await runCreate(SESSION_X) + expect(h.db.getRunRaw(runId)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_actor: ACTOR_X + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: runId } + }) + + const { task } = await as(SESSION_X, 'orchestration.taskCreate', { spec: 'do it' }) + const taskId = idOf(task) + expect(task).toMatchObject({ run_id: runId, created_by_terminal_handle: null }) + expect(await as(SESSION_X, 'orchestration.taskList', {})).toMatchObject({ runId, count: 1 }) + + const { dispatch } = await as(SESSION_X, 'orchestration.dispatch', { + task: taskId, + to: WORKER_HANDLE + }) + expect(dispatch).toMatchObject({ + assignee_handle: WORKER_HANDLE, + creator_handle: null, + creator_pane_key: null, + creator_actor: ACTOR_X, + depth: 1 + }) + + // The worker writes to its coordinator's public address. + const { message: inbound } = await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ACTOR_X, + subject: 'progress' + }) + expect(inbound).toMatchObject({ to_handle: `run:${runId}`, run_id: runId }) + + const checked = await as(SESSION_X, 'orchestration.check', {}) + expect(checked).toMatchObject({ runId, count: 1, messages: [{ subject: 'progress' }] }) + + const { message: outbound } = await as(SESSION_X, 'orchestration.send', { + to: WORKER_HANDLE, + subject: 'more' + }) + expect(outbound).toMatchObject({ from_handle: ACTOR_X, run_id: runId }) + + const replied = await as(SESSION_X, 'orchestration.reply', { + id: idOf(inbound), + body: 'ack' + }) + expect(replied).toMatchObject({ message: { from_handle: ACTOR_X, to_handle: WORKER_HANDLE } }) + + const { gate } = await as(SESSION_X, 'orchestration.gateCreate', { + task: taskId, + question: 'ship?' + }) + expect(await as(SESSION_X, 'orchestration.gateList', {})).toMatchObject({ runId, count: 1 }) + expect( + await as(SESSION_X, 'orchestration.gateResolve', { + id: idOf(gate), + resolution: 'yes' + }) + ).toMatchObject({ gate: { status: 'resolved' } }) + + expect( + await as(SESSION_X, 'orchestration.taskUpdate', { id: taskId, status: 'completed' }) + ).toMatchObject({ task: { status: 'completed' } }) + }) + + it('asks as a coordinator does: only a supervised worker may ask', async () => { + await runCreate(SESSION_X) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.ask', + { question: 'q', to: WORKER_HANDLE }, + { + sessionId: SESSION_X + } + ) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'dispatch_inactive' } }) + }) + + it("places a worker-start in the session's own workspace", async () => { + const runId = await runCreate(SESSION_X) + vi.spyOn(h.runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) + const placed = vi + .spyOn(h.runtime, 'showManagedTerminalWorkspace') + .mockRejectedValue(new Error('placement reached')) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.workerStart', + { spec: 'work', run: runId, agent: 'claude' }, + { + sessionId: SESSION_X + } + ) + ) + expect(placed).toHaveBeenCalledWith(`id:${WORKSPACE_X}`) + expect(response).toMatchObject({ ok: false, error: { message: 'placement reached' } }) + }) + + it("never lets one chat's run-create unbind another chat's Run", async () => { + const xFirst = await runCreate(SESSION_X, 'x first') + const yRun = await runCreate(SESSION_Y, 'y') + const xSecond = await runCreate(SESSION_X, 'x second') + + expect(await as(SESSION_Y, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: yRun } + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: xSecond } + }) + expect(h.db.getRunRaw(xFirst)?.coordinator_actor).toBeNull() + }) + + it('takes over a bound Run like a terminal does, and fences the previous coordinator', async () => { + const runId = await runCreate(SESSION_X) + await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ACTOR_X, + subject: 'before takeover', + run: runId + }) + + expect(await as(SESSION_Y, 'orchestration.runUse', { id: runId })).toMatchObject({ + run: { id: runId } + }) + + const previous = await h.dispatch( + orchestrationRequest('orchestration.check', { run: runId }, { sessionId: SESSION_X }) + ) + expect(previous).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + message: `This coordinator terminal is no longer bound to Run ${runId}.` + } + }) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: null }) + expect(await as(SESSION_Y, 'orchestration.check', {})).toMatchObject({ + runId, + messages: [{ subject: 'before takeover' }] + }) + }) + + it("wakes the previous coordinator's waiting check as fenced when another session takes over", async () => { + const runId = await runCreate(SESSION_X) + const waiter = vi.spyOn(h.runtime, 'waitForMessage') + const waiting = h.dispatch( + orchestrationRequest( + 'orchestration.check', + { run: runId, wait: true, timeoutMs: 5_000 }, + { + sessionId: SESSION_X + } + ) + ) + await vi.waitFor(() => expect(waiter).toHaveBeenCalledWith(`run:${runId}`, expect.anything())) + + await as(SESSION_Y, 'orchestration.runUse', { id: runId }) + + expect(await waiting).toMatchObject({ + ok: false, + error: { + code: 'consumer_fenced', + message: 'This mailbox consumer was replaced while waiting.' + } + }) + }) + + it('keeps the same actor across a native to terminal-view to native handoff', async () => { + const runId = await runCreate(SESSION_X) + // The terminal view is a PTY: its CLI also carries that terminal's own evidence. + const tuiEvidence = { + terminalHandle: 'term_tui', + paneKey: 'tab_tui:99999999-9999-4999-8999-999999999999', + launchToken: 'tui-token' + } + h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { runtimeKind: 'tui' } })) + await as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to: ACTOR_X, subject: 'tui' }) + + const inTui = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.check', + {}, + { + sessionId: SESSION_X, + evidence: tuiEvidence + } + ) + ) + ) + expect(inTui).toMatchObject({ runId, messages: [{ subject: 'tui' }] }) + + h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { runtimeKind: 'native' } })) + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ + run: { id: runId } + }) + }) + + it('reads a coordinator actor left beside another handle as stale: the handle wins', async () => { + const runId = await runCreate(SESSION_X) + // An older binary rebinding the Run to a terminal rewrites handle and pane, never the actor. + h.db.db + .prepare('UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ? WHERE id = ?') + .run(WORKER_HANDLE, WORKER_PANE, runId) + + expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: null }) + expect(await as(undefined, 'orchestration.runCurrent', { from: WORKER_HANDLE })).toMatchObject({ + run: { id: runId } + }) + }) + + it('replays an idempotent retry from the same session and refuses it from another', async () => { + const first = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X, + requestId: 'retry-1' + } + ) + ) + const retry = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_X, + requestId: 'retry-1' + } + ) + ) + const other = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { + sessionId: SESSION_Y, + requestId: 'retry-1' + } + ) + ) + + const firstRun = idOf(resultOf(first).run) + expect(resultOf(retry)).toMatchObject({ run: { id: firstRun }, mutation: { replayed: true } }) + expect(other).toMatchObject({ ok: false, error: { code: 'request_mismatch' } }) + expect(h.db.listRuns().runs.filter((run) => run.legacy === 0)).toHaveLength(1) + }) +}) + +describe('a structured worker that names itself by session id', () => { + let h: SessionCallerHarness + const workerSession = SESSION_Y + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(workerSession) + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + structuredWorkerIdentities.register({ + handle, + sessionId: workerSession, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + function dispatchToWorker(): { runId: string; dispatchId: string } { + const run = h.db.createRun({ + objective: 'pty coordinator', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: 'tab_coord:12121212-1212-4212-8212-121212121212' + }) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + h.db.insertMessage({ + from: 'term_coord', + to: `dispatch:${dispatch.id}`, + subject: 'instructions', + body: '', + runId: run.id + }) + return { runId: run.id, dispatchId: dispatch.id } + } + + it("reads its own Dispatch mailbox: the session id wins and maps to the worker's handle", async () => { + const { dispatchId } = dispatchToWorker() + expect(h.db.getDispatchContextById(dispatchId)?.assignee_actor).toBe(ACTOR_Y) + + const bySession = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.check', { peek: true }, { sessionId: workerSession }) + ) + ) + expect(bySession).toMatchObject({ messages: [{ subject: 'instructions' }] }) + + const namedByHandle = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.check', + { peek: true, terminal: handle }, + { + sessionId: workerSession + } + ) + ) + ) + expect(namedByHandle).toEqual(bySession) + }) + + it('coordinates with its handle, pane and actor, reachable at both addresses', async () => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'nested' }, + { + sessionId: workerSession + } + ) + ) + ) + const runId = idOf(run) + + expect(h.db.getRunRaw(runId)).toMatchObject({ + coordinator_handle: handle, + coordinator_pane_key: paneKey, + coordinator_actor: ACTOR_Y + }) + expect(h.db.getRunMailboxOwnerIdsForHandle(handle)).toEqual([runId]) + expect(h.db.getRunMailboxOwnerIdsForHandle(ACTOR_Y)).toEqual([runId]) + }) +}) diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index d4a99f54ad5..ef21aa8a2b8 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -63,29 +63,24 @@ export class RpcStreamingDispatcher { return } - const parsedParams = parseRpcRequestParams(request, method, envelopeMeta) - if (parsedParams.error) { - reply(JSON.stringify(parsedParams.error)) - return - } - // Why: before the unary/streaming split, so both branches see the same resolved caller. - let resolved: ResolvedOrchestrationRequest = { request, params: parsedParams.value } + // Why: before params parse and the unary/streaming split, so both branches see one caller. + let resolved: ResolvedOrchestrationRequest = { request } if (claimsOrchestrationSession(request)) { try { - resolved = await resolveOrchestrationSessionCaller( - runtime, - request, - resolved.params, - options - ) + resolved = await resolveOrchestrationSessionCaller(runtime, request, options) } catch (error) { reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) return } } request = resolved.request - const params = resolved.params const orchestrationCaller = resolved.caller + const parsedParams = parseRpcRequestParams(request, method, envelopeMeta) + if (parsedParams.error) { + reply(JSON.stringify(parsedParams.error)) + return + } + const params = parsedParams.value if (!isStreamingMethod(method)) { try { From eed399c61d58e7e9df437cb76dbe3db03a5bf436 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:51:45 -0700 Subject: [PATCH 04/15] test(orchestration): pin the SSH check's pane through the caller-identity lookup --- .../rpc/methods/orchestration/messaging/check-methods.ts | 2 +- src/main/ssh/ssh-remote-orca-cli.test.ts | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index 985ad08ae57..ee52efed6b9 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts @@ -41,7 +41,7 @@ export const ORCHESTRATION_CHECK_METHODS = [ paneKey: runtime.getTerminalPaneKey(handle) ?? params.terminalPaneKey }) const paneKey = caller.paneKey ?? undefined - const boundRun = db.getCurrentRunForCoordinator(caller) + const boundRun = hasRunBindingKey(caller) ? db.getCurrentRunForCoordinator(caller) : undefined if (params.run || boundRun) { return checkRunMailbox({ params, diff --git a/src/main/ssh/ssh-remote-orca-cli.test.ts b/src/main/ssh/ssh-remote-orca-cli.test.ts index 276d9cad87c..637a0c55ce3 100644 --- a/src/main/ssh/ssh-remote-orca-cli.test.ts +++ b/src/main/ssh/ssh-remote-orca-cli.test.ts @@ -84,6 +84,7 @@ describe('runRemoteOrcaCli', () => { getActiveDispatchForIdentity: vi.fn(() => undefined), getActiveDispatchMailboxOwners: vi.fn(() => []), getCurrentRunForPane: vi.fn(() => undefined), + getCurrentRunForCoordinator: vi.fn(() => undefined), getRunMailboxOwnerIdsForHandle: vi.fn(() => []), findActiveRemoteAttachmentForPane: vi.fn(() => undefined) } @@ -584,7 +585,9 @@ describe('runRemoteOrcaCli', () => { ) expect(result.exitCode).toBe(0) - expect(db.getCurrentRunForPane).toHaveBeenCalledWith('tab_ssh:leaf_ssh') + expect(db.getCurrentRunForCoordinator).toHaveBeenCalledWith( + expect.objectContaining({ paneKey: 'tab_ssh:leaf_ssh' }) + ) expect(db.getActiveDispatchForIdentity).toHaveBeenCalledWith( 'term_stale_ssh', 'tab_ssh:leaf_ssh' @@ -608,7 +611,7 @@ describe('runRemoteOrcaCli', () => { ) expect(result.exitCode).toBe(0) - expect(db.getCurrentRunForPane).not.toHaveBeenCalled() + expect(db.getCurrentRunForCoordinator).not.toHaveBeenCalled() expect(db.getActiveDispatchForIdentity).toHaveBeenCalledWith('term_legacy_worker', undefined) }) From 7145ca46a540ee1e9dda0057ee88a8a1d1ddd3a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:54:14 -0700 Subject: [PATCH 05/15] test(orchestration): pin a Run-less session's direct check and receipt binding without a caller param Drops the actor clause from self-dispatch detection: a creator and assignee can only share an actor when they already share a handle or pane. --- .../orchestration/db/dispatch-depth.ts | 3 -- .../run-coordinator-actor-binding.test.ts | 20 -------- .../orchestration-session-coordinator.test.ts | 47 +++++++++++++++++++ 3 files changed, 47 insertions(+), 23 deletions(-) diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index 749989f1eb4..ab4d388bca2 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -57,9 +57,6 @@ export function recordedCreatorIdentity(creator: DispatchCreator): { * no creator and keep counting, which is the pre-v37 answer and fails closed. */ function isSelfCreatedDispatch(row: DispatchContextRow): boolean { - if (row.creator_actor && row.creator_actor === row.assignee_actor) { - return true - } if (row.creator_pane_key && row.assignee_pane_key) { return isEquivalentPaneKey(row.creator_pane_key, row.assignee_pane_key) } diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts index a0de26cf646..51a5322d243 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts @@ -347,24 +347,4 @@ describe('Dispatch actors recorded by every writer', () => { expect(db.getDispatchContextById(started.dispatch.id)?.assignee_actor).toBe(WORKER_ACTOR) }) - - it('counts a Dispatch whose creator and assignee are the same actor as bookkeeping', () => { - db = new OrchestrationDb(':memory:') - const worker = { - kind: 'terminal', - handle: mintStructuredWorkerHandle(), - paneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), - actor: WORKER_ACTOR - } as const - db.createDispatchContext({ - taskId: db.createTask({ runId: 'run_legacy_local', spec: 'own' }).id, - assigneeHandle: worker.handle, - assigneePaneKey: 'tab_elsewhere:33333333-3333-4333-8333-333333333333', - processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), - creator: worker, - maxDepth: UNCAPPED - }) - - expect(db.resolveCreatorDepth(worker)).toBe(0) - }) }) diff --git a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts index 1f091730891..79d4cd95070 100644 --- a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -302,6 +302,53 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( }) }) +describe('a session with no Run, and receipts that carry no caller param', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + it('reads its direct mailbox on a consuming check, as a terminal with a live pane does', async () => { + h.db.insertMessage({ from: WORKER_HANDLE, to: ACTOR_X, subject: 'direct', body: '' }) + + const checked = resultOf( + await h.dispatch(orchestrationRequest('orchestration.check', {}, { sessionId: SESSION_X })) + ) + + expect(checked).toMatchObject({ messages: [{ subject: 'direct', to_handle: ACTOR_X }] }) + }) + + it('binds a receipt to the session even when the method names no caller', async () => { + const reset = (sessionId: string) => + h.dispatch( + orchestrationRequest( + 'orchestration.reset', + { messages: true }, + { + sessionId, + requestId: 'reset-1' + } + ) + ) + + expect(await reset(SESSION_X)).toMatchObject({ ok: true }) + expect(await reset(SESSION_X)).toMatchObject({ + ok: true, + result: { mutation: { replayed: true } } + }) + expect(await reset(SESSION_Y)).toMatchObject({ + ok: false, + error: { code: 'request_mismatch' } + }) + }) +}) + describe('a structured worker that names itself by session id', () => { let h: SessionCallerHarness const workerSession = SESSION_Y From bb3c1249dc4fec739a978ff0c32558587b5ca182 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:30:04 -0700 Subject: [PATCH 06/15] test(orchestration): pin a session's own Run for plain and group sends and the assignee-only mail sweep --- .../run-coordinator-actor-binding.test.ts | 37 +++++++++++++++++++ .../orchestration-session-coordinator.test.ts | 30 +++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts index 51a5322d243..bd017e60091 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts @@ -275,6 +275,43 @@ describe('mail owned by an active Dispatch assignee addressed by its actor', () }) }) +describe('stray mail to a session address that is only an assignee', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + it('sweeps it into the Dispatch mailbox, as stray mail to an assignee handle is', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'pty coordinator', + coordinatorHandle: 'term_c', + coordinatorPaneKey: OTHER_PANE + }) + const dispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: { kind: 'system' }, + maxDepth: UNCAPPED + }) + // The worker coordinates nothing, so no address cache entry can claim this mail. + const stray = db.insertMessage({ + from: 'term_c', + to: WORKER_ACTOR, + subject: 's', + body: '', + runId: run.id + }) + expect(db.getMessageById(stray.id)?.to_handle).toBe(WORKER_ACTOR) + + expect(db.routeForeignDirectMessagesToOwnedMailboxes(WORKER_ACTOR).routedCount).toBe(1) + expect(db.getMessageById(stray.id)?.to_handle).toBe(`dispatch:${dispatch.id}`) + }) +}) + describe('Dispatch actors recorded by every writer', () => { let db: OrchestrationDb diff --git a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts index 79d4cd95070..95a8c1819b0 100644 --- a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -117,6 +117,36 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( ).toMatchObject({ task: { status: 'completed' } }) }) + it("files a session's mail to a plain terminal under its own Run", async () => { + const runId = await runCreate(SESSION_X) + const { message } = await as(SESSION_X, 'orchestration.send', { + to: WORKER_HANDLE, + subject: 'no dispatch here' + }) + expect(message).toMatchObject({ from_handle: ACTOR_X, to_handle: WORKER_HANDLE, run_id: runId }) + }) + + it("addresses a group to the session's own Run", async () => { + await runCreate(SESSION_X) + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.send', + { to: '@all', subject: 'everyone' }, + { + sessionId: SESSION_X + } + ) + ) + // The audience resolved to the session's Run; that Run simply has no workers yet. + expect(response).toMatchObject({ + ok: false, + error: { + code: 'terminal_not_found', + message: 'No recipients resolved for group address: @all' + } + }) + }) + it('asks as a coordinator does: only a supervised worker may ask', async () => { await runCreate(SESSION_X) const response = await h.dispatch( From 0fddf277fd762198eeff3016b1e03389952a42e6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:42:15 -0700 Subject: [PATCH 07/15] test(orchestration): name the party-naming field population for its role --- .../runtime/rpc/orchestration-session-caller.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index fdc16336b43..d6344b9120b 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -31,7 +31,7 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' })) // Fields that name a party. They name the caller only in the methods ORCHESTRATION_CALLER_PARAM lists. -const CALLER_SHAPED_FIELDS = ['from', 'terminal', 'callerTerminalHandle'] as const +const PARTY_NAMING_FIELDS = ['from', 'terminal', 'callerTerminalHandle'] as const // Methods with such a field that never reads it as the caller's identity, for any actor. const NAMES_A_PARTY_BUT_NOT_THE_CALLER: Readonly> = { 'orchestration.run': 'retired; refused before any handler', @@ -75,12 +75,12 @@ describe('orchestration session callers at the dispatch entry', () => { it('lists exactly the methods whose params name their caller, and classifies every other one', () => { const registry = buildRegistry(ORCHESTRATION_METHODS) - const callerShaped = [...registry.values()] + const partyNaming = [...registry.values()] .filter((method) => { const schema = method.params return ( schema instanceof ZodObject && - CALLER_SHAPED_FIELDS.some((field) => Object.hasOwn(schema.shape, field)) + PARTY_NAMING_FIELDS.some((field) => Object.hasOwn(schema.shape, field)) ) }) .map((method) => method.name) @@ -88,8 +88,8 @@ describe('orchestration session callers at the dispatch entry', () => { // The population: 41 registered methods, 21 of which carry a party-naming field. expect(registry.size).toBe(41) - expect(callerShaped).toHaveLength(21) - expect(callerShaped).toEqual( + expect(partyNaming).toHaveLength(21) + expect(partyNaming).toEqual( [ ...Object.keys(ORCHESTRATION_CALLER_PARAM), ...Object.keys(NAMES_A_PARTY_BUT_NOT_THE_CALLER) From 44e3e6000a2775dac2863b19424c6b4893511bd4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:29:10 -0700 Subject: [PATCH 08/15] fix(orchestration): clear a worker actor an older binary's unbind leaves, and refuse a worker without its identity An older binary unbinds a structured worker's Run by clearing handle and pane, which leaves the actor looking like a handle-less chat binding. The every-open repair clears that shape for actors recorded as structured workers only, and the resolver refuses a worker session whose worker identity is gone, so this binary never writes the shape itself. --- ...tured-worker-orca-session-backfill.test.ts | 54 +++++++++++++++++++ ...structured-worker-orca-session-backfill.ts | 52 +++++++++++++++++- .../rpc/orchestration-session-caller.test.ts | 37 +++++++++++++ .../rpc/orchestration-session-caller.ts | 12 ++++- 4 files changed, 152 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts index d31f5cc241f..1398a869196 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts @@ -154,4 +154,58 @@ describe('structured worker Orca session id backfill', () => { expect(filled?.coordinator_orca_session_id_generation).toBe(filled?.consumer_generation) expect(db.getRunRaw(recorded)?.coordinator_orca_session_id).toBe(SESSION_C) }) + + it("clears a worker's actor an older binary's unbind left behind, never a chat's binding", () => { + db = new OrchestrationDb(':memory:') + const workerActor = `session:${SESSION_A}` + const chatActor = `session:${SESSION_B}` + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_A) + dispatch({ handle, paneKey, incarnation: structuredWorkerProcessIncarnation(SESSION_A) }) + const unbound = db.createRun({ + objective: 'worker coordinated, then unbound by an older binary', + coordinatorHandle: handle, + coordinatorPaneKey: paneKey, + coordinatorActor: workerActor + }) + const chat = db.createRun({ + objective: 'a chat coordinates', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorActor: chatActor + }) + // The older binary's pane unbind clears handle and pane and cannot see the actor. + db.db + .prepare( + 'UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL WHERE id = ?' + ) + .run(unbound.id) + const handleless = (actor: string) => ({ terminalHandle: null, paneKey: null, actor }) + // The residue reads as that session's handle-less binding until it is repaired. + expect(db.getCurrentRunForCoordinator(handleless(workerActor))?.id).toBe(unbound.id) + + backfillStructuredWorkerActors(db.db) + + expect(db.getRunRaw(unbound.id)?.coordinator_actor).toBeNull() + expect(db.getCurrentRunForCoordinator(handleless(workerActor))).toBeUndefined() + expect(db.getRunRaw(chat.id)?.coordinator_actor).toBe(chatActor) + expect(db.getCurrentRunForCoordinator(handleless(chatActor))?.id).toBe(chat.id) + }) + + it("keeps a worker's actor while its Run still carries its handle", () => { + db = new OrchestrationDb(':memory:') + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_A) + dispatch({ handle, paneKey, incarnation: structuredWorkerProcessIncarnation(SESSION_A) }) + const bound = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: handle, + coordinatorPaneKey: paneKey, + coordinatorActor: `session:${SESSION_A}` + }) + + backfillStructuredWorkerActors(db.db) + + expect(db.getRunRaw(bound.id)?.coordinator_actor).toBe(`session:${SESSION_A}`) + }) }) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts index 59a0bdb1c7f..6fafd616571 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts @@ -4,7 +4,8 @@ import { STRUCTURED_WORKER_HANDLE_PREFIX, STRUCTURED_WORKER_INCARNATION_PREFIX, isStructuredWorkerHandle, - sessionIdFromStructuredWorkerIncarnation + sessionIdFromStructuredWorkerIncarnation, + structuredWorkerProcessIncarnation } from '../../../structured-worker-identity' import { currentRunCoordinatorOrcaSessionIdSql } from '../runs/run-coordinator-orca-session' @@ -32,7 +33,8 @@ const RECORDED_WORKER_SESSIONS_SQL = ` * * Runs after migrate on every open, not only once at v42: a binary rolled back past v42 keeps * writing structured-worker rows without an Orca session id after user_version is already 42. It - * fills only rows with no id that counts, so an id a writer recorded is never rewritten. + * fills only rows with no id that counts, so an id a writer recorded is never rewritten; the one + * clear is the unbind residue below. */ export function backfillStructuredWorkerOrcaSessionIds(db: Database.Database): void { let recordedSessions: Map> | undefined @@ -112,6 +114,52 @@ export function backfillStructuredWorkerOrcaSessionIds(db: Database.Database): v setCoordinator.run(orcaSessionId, row.id) } } + clearUnboundStructuredWorkerCoordinatorOrcaSessionIds(db) +} + +/** + * Whether this Orca session id was assigned a Dispatch as a structured worker. Such a session always + * binds a Run with its worker handle, so it can never hold a handle-less binding. + */ +export function isRecordedStructuredWorkerOrcaSessionId( + db: Database.Database, + orcaSessionId: string +): boolean { + return Boolean( + db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE assignee_orca_session_id = ? AND process_incarnation = ? LIMIT 1` + ) + .get(orcaSessionId, structuredWorkerProcessIncarnation(orcaSessionId)) + ) +} + +/** + * A binary without the Orca session id column unbinds a structured worker's Run by clearing its + * handle and pane, which leaves the id looking like a handle-less chat's binding. Only that unbind + * can make this shape for a worker's id, so the id goes; a chat coordinator's binding is untouched. + */ +function clearUnboundStructuredWorkerCoordinatorOrcaSessionIds(db: Database.Database): void { + const handleless = db + .prepare( + `SELECT id, coordinator_orca_session_id FROM runs + WHERE coordinator_orca_session_id IS NOT NULL AND coordinator_handle IS NULL + AND coordinator_pane_key IS NULL` + ) + .all() + const clear = db.prepare( + `UPDATE runs SET coordinator_orca_session_id = NULL + WHERE id = ? AND coordinator_handle IS NULL AND coordinator_pane_key IS NULL` + ) + for (const row of handleless) { + const orcaSessionId = row.coordinator_orca_session_id + if (typeof row.id === 'string' && typeof orcaSessionId === 'string') { + if (isRecordedStructuredWorkerOrcaSessionId(db, orcaSessionId)) { + clear.run(row.id) + } + } + } } function recordedWorkerSessionsByHandle(db: Database.Database): Map> { diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index d6344b9120b..7aeb669614a 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -9,6 +9,11 @@ import { DeviceRegistry } from '../device-registry' import { OrcaRuntimeRpcServer } from '../runtime-rpc' import { buildRegistry } from './core' import { ORCHESTRATION_METHODS } from './methods/orchestration' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' import { ACTOR_X, createSessionCallerHarness, @@ -17,6 +22,7 @@ import { idOf, resultOf, SESSION_X, + SESSION_Y, sessionRecord, type SessionCallerHarness } from './orchestration-session-caller-test-fixture' @@ -289,6 +295,37 @@ describe('orchestration session callers at the dispatch entry', () => { }) }) + it('a structured worker session whose worker identity this host no longer has', async () => { + // A Dispatch recorded the session as a worker; no registry entry or custody row maps it now. + const run = h.db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_c', + coordinatorPaneKey: 'tab_c:13131313-1313-4313-8313-131313131313' + }) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId: run.id, spec: 'work' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(SESSION_Y), + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const response = await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: SESSION_Y } + ) + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: CODES.notLive, message: expect.stringContaining('no longer has') } + }) + expect(h.db.listRuns().runs.filter((row) => row.coordinator_actor !== null)).toEqual([]) + }) + it('an agent-session host that cannot be brought up to verify it', async () => { hostRef.current = null await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /cannot be verified/) diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index 4acf61ba611..de5c25e1045 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -24,6 +24,7 @@ import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-w import type { OrcaRuntimeService } from '../orca-runtime' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' import { OrchestrationError } from '../orchestration/orchestration-error' +import { isRecordedStructuredWorkerActor } from '../orchestration/db/schema/structured-worker-actor-backfill' import { resolveStructuredWorkerIdentityForSession } from '../structured-worker-authority' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { RpcRequest } from './core' @@ -107,7 +108,16 @@ export async function resolveOrchestrationSessionCaller( const sessionId = actor.id const record = await readSessionRecord(runtime, sessionId) assertSessionCanAct(sessionId, record) - const worker = resolveStructuredWorkerIdentityForSession(sessionId, runtime.getOrchestrationDb()) + const db = runtime.getOrchestrationDb() + const worker = resolveStructuredWorkerIdentityForSession(sessionId, db) + if (!worker && isRecordedStructuredWorkerActor(db.db, formatOrchestrationActor(actor))) { + // Why: acting handle-less would split one worker into two identities, and bind like a chat. + throw new OrchestrationError( + CODES.notLive, + `Agent session ${sessionId} is a structured worker whose worker identity this host no longer has, so it cannot act in orchestration. No effects were applied.`, + NO_EFFECTS + ) + } const terminalHandle = worker?.handle ?? null const caller: OrchestrationSessionCaller = Object.freeze({ sessionId, From 3f44d2a2c8d99451e2e35efa61060e63dc6c1e82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:17:40 -0700 Subject: [PATCH 09/15] refactor(orchestration): read a Run's coordinator actor through its generation, and give a party's addresses one owner The coordinator actor now counts only at the consumer generation it was written at, so a Run binding matches a session by that rule alone. It replaces two mechanisms for the same fact: the rule that an actor beside a handle it did not bind with never matches, and the open-time repair that cleared a structured worker's actor an older binary's unbind left. Every write of an older binary that rebinds or unbinds bumps the generation, so both shapes stop counting by themselves, including a chat's actor after a rebind then an unbind, which neither old mechanism caught. createRun and the same-coordinator actor correction write the generation in the statement that writes the actor. The resolver still refuses a structured worker whose worker identity is gone; its predicate moves next to the worker identity lookup. addressSpellingsOf is the one owner of the addresses a party is reachable at (a structured worker's handle and session actor). createRun, bindRun, the coordinator unbind and the declared-caller check use it instead of hand-built sets, and each has a test at both of a worker's addresses. --- .../orchestration/db/runs/run-binding.ts | 27 ++-- .../run-coordinator-actor-binding.test.ts | 119 +++++++++++++++--- .../orchestration/db/runs/run-create.ts | 12 +- .../orchestration/db/runs/run-lookup.ts | 8 +- ...tured-worker-orca-session-backfill.test.ts | 54 -------- ...structured-worker-orca-session-backfill.ts | 52 +------- .../orchestration-caller-identity.ts | 30 +++-- .../rpc/orchestration-session-caller.ts | 15 ++- .../orchestration-session-coordinator.test.ts | 30 ++++- .../runtime/structured-worker-authority.ts | 19 +++ 10 files changed, 207 insertions(+), 159 deletions(-) diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index eb7344fddbc..f408026d9db 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -3,7 +3,11 @@ import { OrchestrationError } from '../../orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../contract-constants' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' -import { runBoundToCoordinator } from '../../orchestration-caller-identity' +import { + addressSpellingsOf, + runBoundToCoordinator, + runCoordinatorKey +} from '../../orchestration-caller-identity' export function bindRun( this: OrchestrationDb, @@ -117,15 +121,11 @@ export function bindRun( ) } this.unbindOtherRunsForCoordinator(coordinator, params.runId) - // Both coordinators' addresses, and a structured worker's session address beside its handle. - for (const address of new Set( - [ - run.coordinator_handle, - run.coordinator_actor, - coordinator.terminalHandle, - coordinator.actor - ].filter((value): value is string => Boolean(value)) - )) { + // Every address of the coordinator being replaced and of the one binding now. + for (const address of new Set([ + ...addressSpellingsOf(runCoordinatorKey(run)), + ...addressSpellingsOf(coordinator) + ])) { this.rememberRunCoordinatorHandle(params.runId, address) this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, address) } @@ -159,10 +159,13 @@ export function bindRun( if (params.takeoverLegacy || replacesLegacyCoordinator) { this.promoteLegacyCoordinatorMailForTakeover(params.runId, retainedCoordinatorHandle) } - } else if (run.coordinator_actor !== coordinator.actor) { + } else if (runCoordinatorKey(run).actor !== coordinator.actor) { // Same coordinator, so no new consumer: correct an actor a writer without the column left. this.db - .prepare('UPDATE runs SET coordinator_actor = ? WHERE id = ?') + .prepare( + `UPDATE runs SET coordinator_actor = ?, coordinator_actor_generation = consumer_generation + WHERE id = ?` + ) .run(coordinator.actor, params.runId) } this.db.exec('COMMIT') diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts index bd017e60091..d5663cb28a2 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts @@ -10,6 +10,7 @@ const CHAT_X = 'session:1b6f0c3a-7d2e-4a91-8c55-2e9d4b7a0f13' const CHAT_Y = 'session:6d2a9e41-0c7b-4f38-9a15-b3e8c1d57f20' const WORKER_SESSION = '9c3e5a17-4b2d-4f60-8e91-0d7a6c2b5e48' const WORKER_ACTOR = `session:${WORKER_SESSION}` +const OTHER_WORKER_SESSION = '2e8b4d61-5a3c-4e97-b0f2-7c1d9a6e3b54' const PTY_PANE = 'tab_pty:11111111-1111-4111-8111-111111111111' const OTHER_PANE = 'tab_other:22222222-2222-4222-8222-222222222222' const UNCAPPED = Number.MAX_SAFE_INTEGER @@ -38,14 +39,42 @@ describe('Run binding by orchestration actor', () => { return db.insertMessage({ from: 'term_sender', to, subject, body: '', runId }) } - function structuredWorker() { + function structuredWorker(sessionId = WORKER_SESSION) { return { terminalHandle: mintStructuredWorkerHandle(), - paneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), - actor: WORKER_ACTOR + paneKey: mintStructuredWorkerPaneKey(sessionId), + actor: `session:${sessionId}` } } + // A binary without the actor column: its bindRun and unbindOtherRunsForPane statements. + function olderBinaryRebind(runId: string, handle: string, paneKey: string) { + db.db + .prepare( + `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run(handle, paneKey, runId) + } + + function olderBinaryUnbind(runId: string) { + db.db + .prepare( + `UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run(runId) + } + + /** Unread mail addressed straight to `to`, as a row written before the address was cached. */ + function strayMail(runId: string, to: string) { + const message = directMail(runId, 'term_late', `to ${to}`) + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(to, message.id) + return message.id + } + it('binds a handle-less session by its actor and remembers the actor as its address', () => { db = new OrchestrationDb(':memory:') const run = createChatRun(CHAT_X) @@ -96,13 +125,10 @@ describe('Run binding by orchestration actor', () => { expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${xFirst.id}`) }) - it('reads an actor beside a handle it does not bind with as stale, and the handle wins', () => { + it('stops counting an actor once a binary without the column rebinds the Run to a terminal', () => { db = new OrchestrationDb(':memory:') const run = createChatRun(CHAT_X) - // What a binary without the actor column leaves when it rebinds the Run to a terminal. - db.db - .prepare('UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ? WHERE id = ?') - .run('term_taker', PTY_PANE, run.id) + olderBinaryRebind(run.id, 'term_taker', PTY_PANE) expect(db.getCurrentRunForCoordinator(chat(CHAT_X))).toBeUndefined() expect( @@ -116,7 +142,24 @@ describe('Run binding by orchestration actor', () => { expect(db.getRunRaw(run.id)?.coordinator_handle).toBe('term_taker') }) - it("reads a structured worker's actor as stale once its handle is gone from the Run", () => { + it('does not hand a chat back a Run an older binary rebound and then unbound', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X) + olderBinaryRebind(run.id, 'term_taker', PTY_PANE) + olderBinaryUnbind(run.id) + + // Handle and pane are gone and the actor is still there: the shape of a live chat binding. + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_actor: CHAT_X + }) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X))).toBeUndefined() + const next = createChatRun(CHAT_X, 'next') + expect(db.getCurrentRunForCoordinator(chat(CHAT_X))?.id).toBe(next.id) + }) + + it("stops counting a structured worker's actor once an older binary unbinds its Run", () => { db = new OrchestrationDb(':memory:') const worker = structuredWorker() const run = db.createRun({ @@ -126,13 +169,9 @@ describe('Run binding by orchestration actor', () => { coordinatorActor: worker.actor }) expect(db.getCurrentRunForCoordinator(worker)?.id).toBe(run.id) - // An older binary's pane unbind clears handle and pane but not the actor. - db.db - .prepare( - 'UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL WHERE id = ?' - ) - .run(run.id) + olderBinaryUnbind(run.id) expect(db.getCurrentRunForCoordinator(worker)).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(worker.actor))).toBeUndefined() }) it('remembers a coordinating structured worker at its handle and its session address', () => { @@ -195,6 +234,56 @@ describe('Run binding by orchestration actor', () => { coordinator_actor: WORKER_ACTOR, consumer_generation: before }) + // Written at the current generation, so the filled actor counts on its own. + expect(db.getCurrentRunForCoordinator(chat(WORKER_ACTOR))?.id).toBe(run.id) + }) + + it("reroutes and remembers both of each worker's addresses when one takes a Run from another", () => { + db = new OrchestrationDb(':memory:') + const first = structuredWorker() + const second = structuredWorker(OTHER_WORKER_SESSION) + const run = db.createRun({ + objective: 'first worker coordinates', + coordinatorHandle: first.terminalHandle, + coordinatorPaneKey: first.paneKey, + coordinatorActor: first.actor + }) + const addresses = [first.terminalHandle, first.actor, second.terminalHandle, second.actor] + const stray = addresses.map((address) => strayMail(run.id, address)) + + db.bindRun({ + runId: run.id, + coordinatorHandle: second.terminalHandle, + coordinatorPaneKey: second.paneKey, + coordinatorActor: second.actor + }) + + for (const id of stray) { + expect(db.getMessageById(id)?.to_handle).toBe(`run:${run.id}`) + } + for (const address of addresses) { + expect(db.getRunMailboxOwnerIdsForHandle(address)).toEqual([run.id]) + } + }) + + it("reroutes both of a worker's addresses when its next Run unbinds the last", () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const bind = { + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey, + coordinatorActor: worker.actor + } + const last = db.createRun({ objective: 'last', ...bind }) + const stray = [worker.terminalHandle, worker.actor].map((address) => + strayMail(last.id, address) + ) + + db.createRun({ objective: 'next', ...bind }) + + for (const id of stray) { + expect(db.getMessageById(id)?.to_handle).toBe(`run:${last.id}`) + } }) }) diff --git a/src/main/runtime/orchestration/db/runs/run-create.ts b/src/main/runtime/orchestration/db/runs/run-create.ts index a41a898ee76..73698f268a2 100644 --- a/src/main/runtime/orchestration/db/runs/run-create.ts +++ b/src/main/runtime/orchestration/db/runs/run-create.ts @@ -1,6 +1,7 @@ import type { RunRow } from '../../types' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' +import { addressSpellingsOf } from '../../orchestration-caller-identity' // ── Runs ── @@ -27,15 +28,12 @@ export function createRun( .prepare( `INSERT INTO runs ( id, objective, coordinator_handle, coordinator_pane_key, coordinator_actor, - consumer_generation, legacy - ) VALUES (?, ?, ?, ?, ?, 1, 0)` + coordinator_actor_generation, consumer_generation, legacy + ) VALUES (?, ?, ?, ?, ?, 1, 1, 0)` ) .run(id, params.objective, coordinator.terminalHandle, coordinator.paneKey, coordinator.actor) - // A structured worker is addressed by its handle and its session, so both reach this Run. - for (const address of new Set([coordinator.terminalHandle, coordinator.actor])) { - if (address) { - this.rememberRunCoordinatorHandle(id, address) - } + for (const address of addressSpellingsOf(coordinator)) { + this.rememberRunCoordinatorHandle(id, address) } this.db.exec('COMMIT') } catch (error) { diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index d8e0ca25505..b634f110b37 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -1,6 +1,8 @@ import type { RunRow } from '../../types' import { + addressSpellingsOf, runBoundToCoordinator, + runCoordinatorKey, type OrchestrationCoordinatorKey } from '../../orchestration-caller-identity' import { ORCHESTRATION_RUN_PAGE_LIMIT } from '../../../../../shared/orchestration-run-pagination' @@ -162,10 +164,8 @@ export function unbindOtherRunsForCoordinator( ): void { for (const run of this.runsBoundToCoordinator(caller)) { if (run.id !== exceptRunId) { - for (const address of new Set([run.coordinator_handle, run.coordinator_actor])) { - if (address) { - this.routeAllUnreadDirectMessagesToRunMailbox(run.id, address) - } + for (const address of addressSpellingsOf(runCoordinatorKey(run))) { + this.routeAllUnreadDirectMessagesToRunMailbox(run.id, address) } this.db .prepare( diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts index 1398a869196..d31f5cc241f 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts @@ -154,58 +154,4 @@ describe('structured worker Orca session id backfill', () => { expect(filled?.coordinator_orca_session_id_generation).toBe(filled?.consumer_generation) expect(db.getRunRaw(recorded)?.coordinator_orca_session_id).toBe(SESSION_C) }) - - it("clears a worker's actor an older binary's unbind left behind, never a chat's binding", () => { - db = new OrchestrationDb(':memory:') - const workerActor = `session:${SESSION_A}` - const chatActor = `session:${SESSION_B}` - const handle = mintStructuredWorkerHandle() - const paneKey = mintStructuredWorkerPaneKey(SESSION_A) - dispatch({ handle, paneKey, incarnation: structuredWorkerProcessIncarnation(SESSION_A) }) - const unbound = db.createRun({ - objective: 'worker coordinated, then unbound by an older binary', - coordinatorHandle: handle, - coordinatorPaneKey: paneKey, - coordinatorActor: workerActor - }) - const chat = db.createRun({ - objective: 'a chat coordinates', - coordinatorHandle: null, - coordinatorPaneKey: null, - coordinatorActor: chatActor - }) - // The older binary's pane unbind clears handle and pane and cannot see the actor. - db.db - .prepare( - 'UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL WHERE id = ?' - ) - .run(unbound.id) - const handleless = (actor: string) => ({ terminalHandle: null, paneKey: null, actor }) - // The residue reads as that session's handle-less binding until it is repaired. - expect(db.getCurrentRunForCoordinator(handleless(workerActor))?.id).toBe(unbound.id) - - backfillStructuredWorkerActors(db.db) - - expect(db.getRunRaw(unbound.id)?.coordinator_actor).toBeNull() - expect(db.getCurrentRunForCoordinator(handleless(workerActor))).toBeUndefined() - expect(db.getRunRaw(chat.id)?.coordinator_actor).toBe(chatActor) - expect(db.getCurrentRunForCoordinator(handleless(chatActor))?.id).toBe(chat.id) - }) - - it("keeps a worker's actor while its Run still carries its handle", () => { - db = new OrchestrationDb(':memory:') - const handle = mintStructuredWorkerHandle() - const paneKey = mintStructuredWorkerPaneKey(SESSION_A) - dispatch({ handle, paneKey, incarnation: structuredWorkerProcessIncarnation(SESSION_A) }) - const bound = db.createRun({ - objective: 'worker coordinates', - coordinatorHandle: handle, - coordinatorPaneKey: paneKey, - coordinatorActor: `session:${SESSION_A}` - }) - - backfillStructuredWorkerActors(db.db) - - expect(db.getRunRaw(bound.id)?.coordinator_actor).toBe(`session:${SESSION_A}`) - }) }) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts index 6fafd616571..59a0bdb1c7f 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts @@ -4,8 +4,7 @@ import { STRUCTURED_WORKER_HANDLE_PREFIX, STRUCTURED_WORKER_INCARNATION_PREFIX, isStructuredWorkerHandle, - sessionIdFromStructuredWorkerIncarnation, - structuredWorkerProcessIncarnation + sessionIdFromStructuredWorkerIncarnation } from '../../../structured-worker-identity' import { currentRunCoordinatorOrcaSessionIdSql } from '../runs/run-coordinator-orca-session' @@ -33,8 +32,7 @@ const RECORDED_WORKER_SESSIONS_SQL = ` * * Runs after migrate on every open, not only once at v42: a binary rolled back past v42 keeps * writing structured-worker rows without an Orca session id after user_version is already 42. It - * fills only rows with no id that counts, so an id a writer recorded is never rewritten; the one - * clear is the unbind residue below. + * fills only rows with no id that counts, so an id a writer recorded is never rewritten. */ export function backfillStructuredWorkerOrcaSessionIds(db: Database.Database): void { let recordedSessions: Map> | undefined @@ -114,52 +112,6 @@ export function backfillStructuredWorkerOrcaSessionIds(db: Database.Database): v setCoordinator.run(orcaSessionId, row.id) } } - clearUnboundStructuredWorkerCoordinatorOrcaSessionIds(db) -} - -/** - * Whether this Orca session id was assigned a Dispatch as a structured worker. Such a session always - * binds a Run with its worker handle, so it can never hold a handle-less binding. - */ -export function isRecordedStructuredWorkerOrcaSessionId( - db: Database.Database, - orcaSessionId: string -): boolean { - return Boolean( - db - .prepare( - `SELECT 1 FROM dispatch_contexts - WHERE assignee_orca_session_id = ? AND process_incarnation = ? LIMIT 1` - ) - .get(orcaSessionId, structuredWorkerProcessIncarnation(orcaSessionId)) - ) -} - -/** - * A binary without the Orca session id column unbinds a structured worker's Run by clearing its - * handle and pane, which leaves the id looking like a handle-less chat's binding. Only that unbind - * can make this shape for a worker's id, so the id goes; a chat coordinator's binding is untouched. - */ -function clearUnboundStructuredWorkerCoordinatorOrcaSessionIds(db: Database.Database): void { - const handleless = db - .prepare( - `SELECT id, coordinator_orca_session_id FROM runs - WHERE coordinator_orca_session_id IS NOT NULL AND coordinator_handle IS NULL - AND coordinator_pane_key IS NULL` - ) - .all() - const clear = db.prepare( - `UPDATE runs SET coordinator_orca_session_id = NULL - WHERE id = ? AND coordinator_handle IS NULL AND coordinator_pane_key IS NULL` - ) - for (const row of handleless) { - const orcaSessionId = row.coordinator_orca_session_id - if (typeof row.id === 'string' && typeof orcaSessionId === 'string') { - if (isRecordedStructuredWorkerOrcaSessionId(db, orcaSessionId)) { - clear.run(row.id) - } - } - } } function recordedWorkerSessionsByHandle(db: Database.Database): Map> { diff --git a/src/main/runtime/orchestration/orchestration-caller-identity.ts b/src/main/runtime/orchestration/orchestration-caller-identity.ts index 89ef5fe3a5e..72b463b34b1 100644 --- a/src/main/runtime/orchestration/orchestration-caller-identity.ts +++ b/src/main/runtime/orchestration/orchestration-caller-identity.ts @@ -1,5 +1,6 @@ import type { RunRow } from './types' import { isEquivalentPaneKey } from './db/pane-key-match' +import { currentRunCoordinatorActor } from './db/runs/run-coordinator-actor' /** * Who an orchestration caller is, as Run binding and mail routing match it. @@ -38,11 +39,26 @@ export function hasRunBindingKey(caller: OrchestrationCoordinatorKey): boolean { } /** - * An actor binding counts only while the row's handle is the one that actor binds with: none for a - * handle-less session, its own handle for a structured worker. Every binary that predates the actor - * column rewrites `coordinator_handle` whenever it rebinds or unbinds a Run, so an actor it left - * behind can never satisfy this and is ignored without a cleanup pass. + * Every address one party is reachable at. A structured worker has two, its handle and its session + * actor, so every consumer that remembers, reroutes or compares a party's mail takes this set. */ +export function addressSpellingsOf( + party: Pick +): string[] { + return [...new Set([party.terminalHandle, party.actor])].filter( + (address): address is string => address !== null + ) +} + +/** Who a Run's binding names now; an actor an older binding left behind is not part of it. */ +export function runCoordinatorKey(run: RunRow): OrchestrationCoordinatorKey { + return { + terminalHandle: run.coordinator_handle, + paneKey: run.coordinator_pane_key, + actor: currentRunCoordinatorActor(run) + } +} + export function runBoundToCoordinator(run: RunRow, caller: OrchestrationCoordinatorKey): boolean { if ( caller.paneKey !== null && @@ -51,9 +67,5 @@ export function runBoundToCoordinator(run: RunRow, caller: OrchestrationCoordina ) { return true } - return ( - caller.actor !== null && - run.coordinator_actor === caller.actor && - run.coordinator_handle === caller.terminalHandle - ) + return caller.actor !== null && currentRunCoordinatorActor(run) === caller.actor } diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index de5c25e1045..0057163036b 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -22,10 +22,15 @@ import { import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from '../orca-runtime' -import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' +import { + addressSpellingsOf, + type OrchestrationSessionCaller +} from '../orchestration/orchestration-caller-identity' import { OrchestrationError } from '../orchestration/orchestration-error' -import { isRecordedStructuredWorkerActor } from '../orchestration/db/schema/structured-worker-actor-backfill' -import { resolveStructuredWorkerIdentityForSession } from '../structured-worker-authority' +import { + isRecordedStructuredWorkerSession, + resolveStructuredWorkerIdentityForSession +} from '../structured-worker-authority' import { structuredWorkerHostScope } from '../structured-worker-identity' import type { RpcRequest } from './core' @@ -110,7 +115,7 @@ export async function resolveOrchestrationSessionCaller( assertSessionCanAct(sessionId, record) const db = runtime.getOrchestrationDb() const worker = resolveStructuredWorkerIdentityForSession(sessionId, db) - if (!worker && isRecordedStructuredWorkerActor(db.db, formatOrchestrationActor(actor))) { + if (!worker && isRecordedStructuredWorkerSession(sessionId, db)) { // Why: acting handle-less would split one worker into two identities, and bind like a chat. throw new OrchestrationError( CODES.notLive, @@ -223,7 +228,7 @@ function bindDeclaredCaller( } const values: Record = { ...params } const declared = values[name] - const names: unknown[] = [caller.address, caller.actor, caller.sessionId, caller.terminalHandle] + const names: unknown[] = [...addressSpellingsOf(caller), caller.sessionId] if (declared !== undefined && !names.includes(declared)) { throw consumerFenced(caller, String(declared)) } diff --git a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts index 95a8c1819b0..fda4a4cab19 100644 --- a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -280,11 +280,15 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( }) }) - it('reads a coordinator actor left beside another handle as stale: the handle wins', async () => { + it('stops counting a coordinator actor once an older binary rebinds the Run to a terminal', async () => { const runId = await runCreate(SESSION_X) - // An older binary rebinding the Run to a terminal rewrites handle and pane, never the actor. + // An older binary's bindRun rewrites handle and pane and bumps the generation, never the actor. h.db.db - .prepare('UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ? WHERE id = ?') + .prepare( + `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1 + WHERE id = ?` + ) .run(WORKER_HANDLE, WORKER_PANE, runId) expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: null }) @@ -452,6 +456,26 @@ describe('a structured worker that names itself by session id', () => { expect(namedByHandle).toEqual(bySession) }) + it.each([ + ['its handle', handle], + ['its session address', ACTOR_Y], + ['its bare session id', workerSession] + ])('accepts itself declared as %s and binds by its handle', async (_label, declared) => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'declared', from: declared }, + { sessionId: workerSession } + ) + ) + ) + expect(h.db.getRunRaw(idOf(run))).toMatchObject({ + coordinator_handle: handle, + coordinator_actor: ACTOR_Y + }) + }) + it('coordinates with its handle, pane and actor, reachable at both addresses', async () => { const { run } = resultOf( await h.dispatch( diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 1b4a464cb24..15dd6470c09 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -8,6 +8,7 @@ */ import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { formatOrchestrationActor } from '../../shared/orchestration-actor' import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' @@ -63,6 +64,24 @@ export function resolveStructuredWorkerIdentityForSession( return row ? structuredWorkerIdentities.rehydrate(row) : null } +/** + * Whether this session was assigned a Dispatch as a structured worker. Such a session acts with its + * worker handle, so one whose handle is gone must not act handle-less, as a chat would. + */ +export function isRecordedStructuredWorkerSession(sessionId: string, db: OrchestrationDb): boolean { + return Boolean( + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE assignee_actor = ? AND process_incarnation = ? LIMIT 1` + ) + .get( + formatOrchestrationActor({ kind: 'session', id: sessionId }), + structuredWorkerProcessIncarnation(sessionId) + ) + ) +} + /** Identity plus a record that still proves this runtime owns the session. */ export function resolveStructuredWorkerAuthority( handle: string, From 014be09fad6ed544d9af2703fb3619a5a4a052cb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:18:14 -0700 Subject: [PATCH 10/15] fix(orchestration): say a released session is not running, and scope the pane-key credential claim to requests without a session A released lease is evicted, not ended: a user turn resumes the session, so the refusal now says it is not running right now instead of that it has ended. The worker pane-key comment claimed the random leaf is what stops anyone who learns a session id from acting as the worker. On the same-host socket route the session id now names the worker with no token by design; the pane key still matters where a request names no session (a PTY agent's, or the paired-client route, which refuses session ids). --- .../rpc/orchestration-session-caller.test.ts | 2 +- .../runtime/rpc/orchestration-session-caller.ts | 3 ++- src/main/runtime/structured-worker-identity.ts | 13 +++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index 7aeb669614a..c23bd9087bc 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -262,7 +262,7 @@ describe('orchestration session callers at the dispatch entry', () => { it('a released lease', async () => { h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { claimStatus: 'released' } })) - await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /has ended/) + await expectRefusedWithNoEffects(SESSION_X, CODES.notLive, /is not running right now/) }) it('a lease mid-handoff between chat and terminal view', async () => { diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index 0057163036b..fc00b2f0c74 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -205,7 +205,8 @@ function assertSessionCanAct(sessionId: string, record: AgentSessionRecord): voi const { lease } = record const reason = lease.claimStatus === 'released' - ? 'has ended, so it can no longer act in orchestration.' + ? // Why not "ended": a released lease is evicted and wakeable; only a running process may act. + 'is not running right now. A new message or user turn revives it; retry then.' : lease.handoffStage !== null ? 'is switching between chat and terminal view. Retry when the switch finishes.' : 'has no live owner on this host right now. Retry once it is running.' diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index 1bb0fcfa823..921f84a509a 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -61,12 +61,13 @@ export function mintStructuredWorkerHandle(): string { /** * A RANDOM leaf, minted once per worker and persisted with the rest of the identity. * - * Emphatically not `structuredAgentSessionPaneKey`, which is a sha256 of the session id. A pane - * key is an identity credential on its own: `orchestration.check` is identity-gated, not - * capability-gated, and accepts a caller-supplied `terminalPaneKey` that `getActiveDispatchForIdentity` - * matches by leaf suffix. A derivable pane key would therefore let anyone who learns a session id — - * which the tab id embeds in plain text — read and consume that worker's mailbox with no token. - * PTY pane keys are safe only because their leaf UUID is random; this one has to be too. + * Emphatically not `structuredAgentSessionPaneKey`, which is a sha256 of the session id. A request + * that names no session — a PTY agent's, or any on the paired-client route, which refuses session + * ids — identifies its caller by pane: `orchestration.check` accepts a caller-supplied + * `terminalPaneKey` that `getActiveDispatchForIdentity` matches by leaf suffix. A pane key derivable + * from the session id, which the tab id embeds in plain text, would let such a request read and + * consume this worker's mailbox. On the same-host socket route the session id itself names the + * worker with no token, by design; the pane key is no credential there and must not become one. * * Restart stability comes from persisting the minted key, not from re-deriving it. */ From 1dcaf728b761abf5a8959b73adaf970b9ae94447 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:22:29 -0700 Subject: [PATCH 11/15] test(orchestration): pin the same-coordinator actor correction's generation write on a row an older binary wrote --- .../orchestration/db/runs/run-coordinator-actor-binding.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts index d5663cb28a2..d09a3444192 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts @@ -221,6 +221,8 @@ describe('Run binding by orchestration actor', () => { coordinatorHandle: worker.terminalHandle, coordinatorPaneKey: worker.paneKey }) + // As an older binary writes the row: no actor, and no generation for one. + db.db.prepare('UPDATE runs SET coordinator_actor_generation = NULL WHERE id = ?').run(run.id) const before = db.getRunRaw(run.id)?.consumer_generation db.bindRun({ From abe5c3dc079591f1028631c6eaf0d35417573b2c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:35:53 -0700 Subject: [PATCH 12/15] refactor(orchestration): resolve session callers by the bare Orca session id, typed apart from its address Carries the Orca session id rename into caller resolution, Run binding and Dispatch creation. The caller identity holds the bare `orcaSessionId`; the `session:` spelling is derived by formatOrcaSessionAddress wherever mail needs it. `OrcaSessionId` and `OrcaSessionAddress` are distinct branded strings. Only isOrcaSessionId and parseOrcaSessionAddress produce an id, and only formatOrcaSessionAddress produces an address, so comparing the two is a type error. The Orca session id columns on the row types carry the id type. Every reader that compares a stored id with a mail address now compares like with like: the active-Dispatch ownership check formats the stored id (orcaSessionAddressSql), and the stray-mail sweep, the creator nesting lookup and the recorded-worker check bind a parsed or typed bare id. The Run-mailbox ownership check keeps its existing handle comparison beside the session one. --- .../dispatch-context-store.ts | 4 +- .../orchestration/db/dispatch-depth.ts | 27 ++- .../orchestration/db/dispatch-row-writer.ts | 19 +- .../foreign-direct-mailbox-routing.ts | 37 +-- .../db/orca-session-address-sql.ts | 10 + .../orchestration/db/runs/run-binding.ts | 23 +- .../db/runs/run-coordinator-mail-routing.ts | 5 +- ...-coordinator-orca-session-binding.test.ts} | 212 +++++++++++------- .../db/runs/run-coordinator-orca-session.ts | 7 +- .../orchestration/db/runs/run-create.ts | 19 +- .../orchestration/db/runs/run-lookup.ts | 10 +- ...tured-worker-orca-session-backfill.test.ts | 6 +- .../worker-dispatch-authority.ts | 4 +- .../failed-start-dispatch-identity.ts | 4 +- .../orchestration-caller-identity.ts | 44 ++-- ...tion-orca-session-column-migration.test.ts | 5 +- ...n-coordinator-orca-session-address.test.ts | 5 +- ...structured-worker-group-addressing.test.ts | 7 +- src/main/runtime/orchestration/types.ts | 7 +- .../rpc/dispatcher-unary-method-invocation.ts | 2 +- .../orchestration/runs/dispatch-creator.ts | 8 +- .../orchestration/runs/run-receipt.test.ts | 3 +- .../methods/orchestration/runs/run-scope.ts | 6 +- .../rpc/methods/orchestration/runs/runs.ts | 4 +- .../rpc/orchestration-mutation-executor.ts | 7 +- .../rpc/orchestration-mutation-receipt.ts | 9 +- ...chestration-session-caller-test-fixture.ts | 10 +- .../rpc/orchestration-session-caller.test.ts | 24 +- .../rpc/orchestration-session-caller.ts | 18 +- .../orchestration-session-coordinator.test.ts | 94 ++++++-- .../runtime/rpc/rpc-streaming-dispatcher.ts | 2 +- .../runtime/structured-worker-authority.ts | 14 +- .../runtime/structured-worker-identity.ts | 14 +- .../orca-session-address-test-fixture.ts | 9 + src/shared/orca-session-address.test.ts | 6 +- src/shared/orca-session-address.ts | 25 ++- 36 files changed, 447 insertions(+), 263 deletions(-) create mode 100644 src/main/runtime/orchestration/db/orca-session-address-sql.ts rename src/main/runtime/orchestration/db/runs/{run-coordinator-actor-binding.test.ts => run-coordinator-orca-session-binding.test.ts} (66%) create mode 100644 src/shared/orca-session-address-test-fixture.ts diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts index 815bfd9d2ba..67be16e6cf2 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-context-store.ts @@ -9,7 +9,7 @@ import { recordedCreatorIdentity, type DispatchCreator } from '../dispatch-depth import type { OrchestrationDb } from '../orchestration-db' import { transitionLifecycleWithDb } from '../lifecycle-transition' import { taskNotFoundError, taskNotStartableError } from '../../task-dispatch-refusal' -import { structuredWorkerActorForIncarnation } from '../../../structured-worker-identity' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' export function createDispatchContext( this: OrchestrationDb, @@ -66,7 +66,7 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, - assigneeActor: structuredWorkerActorForIncarnation(processIncarnation), + assigneeOrcaSessionId: structuredWorkerOrcaSessionIdForIncarnation(processIncarnation), processIncarnation: processIncarnation ?? null, creatorDispatchId, ...recordedCreatorIdentity(params.creator), diff --git a/src/main/runtime/orchestration/db/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index ab4d388bca2..93c5bd65fb6 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -7,6 +7,7 @@ import { import { OrchestrationError } from '../orchestration-error' import { isEquivalentPaneKey } from './pane-key-match' import type { OrchestrationDb } from './orchestration-db' +import type { OrcaSessionId } from '../../../../shared/orca-session-address' import type { DispatchContextRow, RemoteDispatchAttachmentRow } from '../types' import { potentiallyLiveRemoteAttachmentSql } from './federation/remote-attachment-liveness' @@ -26,28 +27,32 @@ export type DispatchCreator = paneKey?: string /** Remote attachment matching requires the exact incarnation; local rows do not. */ processIncarnation?: string - /** A structured worker's `session:`, recorded beside its handle. */ - actor?: string | null + /** A structured worker's bare Orca session id, recorded beside its handle. */ + orcaSessionId?: OrcaSessionId | null } - /** A structured session with no terminal handle, identified by its actor alone. */ - | { kind: 'actor'; actor: string } + /** A structured session with no terminal handle, identified by its Orca session id alone. */ + | { kind: 'session'; orcaSessionId: OrcaSessionId } /** Creator identity to persist on a new row, so depth can later tell delegation from bookkeeping. */ export function recordedCreatorIdentity(creator: DispatchCreator): { creatorHandle: string | null creatorPaneKey: string | null - creatorActor: string | null + creatorOrcaSessionId: OrcaSessionId | null } { if (creator.kind === 'system') { - return { creatorHandle: null, creatorPaneKey: null, creatorActor: null } + return { creatorHandle: null, creatorPaneKey: null, creatorOrcaSessionId: null } } - if (creator.kind === 'actor') { - return { creatorHandle: null, creatorPaneKey: null, creatorActor: creator.actor } + if (creator.kind === 'session') { + return { + creatorHandle: null, + creatorPaneKey: null, + creatorOrcaSessionId: creator.orcaSessionId + } } return { creatorHandle: creator.handle, creatorPaneKey: creator.paneKey ?? null, - creatorActor: creator.actor ?? null + creatorOrcaSessionId: creator.orcaSessionId ?? null } } @@ -143,10 +148,10 @@ function findActiveDispatchForCreator( const row = this.db .prepare( `SELECT * FROM dispatch_contexts - WHERE assignee_actor = ? AND status IN ('pending', 'dispatched') + WHERE assignee_orca_session_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) - .get(creator.actor) + .get(creator.orcaSessionId) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. return row as DispatchContextRow | undefined } diff --git a/src/main/runtime/orchestration/db/dispatch-row-writer.ts b/src/main/runtime/orchestration/db/dispatch-row-writer.ts index a1f04800fac..e8e4192e137 100644 --- a/src/main/runtime/orchestration/db/dispatch-row-writer.ts +++ b/src/main/runtime/orchestration/db/dispatch-row-writer.ts @@ -1,5 +1,6 @@ import type Database from '../../../sqlite/sync-database' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' +import type { OrcaSessionId } from '../../../../shared/orca-session-address' /** * The only place that inserts rows representing a live supervised worker. @@ -14,8 +15,8 @@ import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' export const DISPATCH_CONTEXT_CLAIM_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, - assignee_handle, assignee_pane_key, assignee_actor, process_incarnation, - creator_dispatch_id, creator_handle, creator_pane_key, creator_actor, + assignee_handle, assignee_pane_key, assignee_orca_session_id, process_incarnation, + creator_dispatch_id, creator_handle, creator_pane_key, creator_orca_session_id, status, failure_count, depth, dispatched_at ) SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') @@ -45,7 +46,7 @@ WHERE id = ? AND status = 'ready' const STARTING_DISPATCH_CONTEXT_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, retry_of_dispatch_id, - creator_dispatch_id, creator_handle, creator_pane_key, creator_actor, depth, status, + creator_dispatch_id, creator_handle, creator_pane_key, creator_orca_session_id, depth, status, dispatched_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` @@ -71,12 +72,12 @@ export function claimDispatchContextRow( launchTokenHash: string | null assigneeHandle: string assigneePaneKey: string | null - assigneeActor?: string | null + assigneeOrcaSessionId?: OrcaSessionId | null processIncarnation: string | null creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null - creatorActor?: string | null + creatorOrcaSessionId?: OrcaSessionId | null priorFailures: number depth: number taskId: string @@ -92,12 +93,12 @@ export function claimDispatchContextRow( params.launchTokenHash, params.assigneeHandle, params.assigneePaneKey, - params.assigneeActor ?? null, + params.assigneeOrcaSessionId ?? null, params.processIncarnation, params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, - params.creatorActor ?? null, + params.creatorOrcaSessionId ?? null, params.priorFailures, params.depth, params.taskId, @@ -123,7 +124,7 @@ export function insertStartingDispatchContextRow( creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null - creatorActor?: string | null + creatorOrcaSessionId?: OrcaSessionId | null } ): void { assertStampedDepth(params.depth) @@ -137,7 +138,7 @@ export function insertStartingDispatchContextRow( params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, - params.creatorActor ?? null, + params.creatorOrcaSessionId ?? null, params.depth ) } diff --git a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts index 1bf45236d60..7e04ae644b8 100644 --- a/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts +++ b/src/main/runtime/orchestration/db/messages/foreign-direct-mailbox-routing.ts @@ -1,5 +1,5 @@ import { parsePaneKey } from '../../../../../shared/stable-pane-id' -import { parseOrchestrationActor } from '../../../../../shared/orchestration-actor' +import { parseOrcaSessionAddress } from '../../../../../shared/orca-session-address' import type { DispatchContextRow, MessageType } from '../../types' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' @@ -25,17 +25,18 @@ export function findActiveDispatchForDirectMessageOwner( return exact } // A session address owns the Dispatch its session is assigned, as a handle owns its own. - if (parseOrchestrationActor(directHandle)) { - const byActor = this.db + const directOrcaSessionId = parseOrcaSessionAddress(directHandle) + if (directOrcaSessionId) { + const bySession = this.db .prepare( `SELECT * FROM dispatch_contexts - WHERE run_id = ? AND assignee_actor = ? AND status IN ('pending', 'dispatched') + WHERE run_id = ? AND assignee_orca_session_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1` ) - .get(runId, directHandle) - if (byActor) { + .get(runId, directOrcaSessionId) + if (bySession) { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. - return byActor as DispatchContextRow + return bySession as DispatchContextRow } } if (!paneKey || !parsePaneKey(paneKey)) { @@ -94,26 +95,28 @@ export function routeForeignDirectMessagesToOwnedMailboxes( [directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1], [directHandle, directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1] ] - if (parseOrchestrationActor(directHandle)) { + const directOrcaSessionId = parseOrcaSessionAddress(directHandle) + if (directOrcaSessionId) { branches.push( `SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence FROM ( - SELECT actor_dispatch.run_id - FROM dispatch_contexts AS actor_dispatch INDEXED BY idx_dispatch_assignee_actor + SELECT session_dispatch.run_id + FROM dispatch_contexts AS session_dispatch + INDEXED BY idx_dispatch_assignee_orca_session_id JOIN runs AS owner_run - ON owner_run.id = actor_dispatch.run_id AND owner_run.legacy = 0 - WHERE actor_dispatch.assignee_actor = ? - AND actor_dispatch.status IN ('pending', 'dispatched') - GROUP BY actor_dispatch.run_id - ) AS actor_owner + ON owner_run.id = session_dispatch.run_id AND owner_run.legacy = 0 + WHERE session_dispatch.assignee_orca_session_id = ? + AND session_dispatch.status IN ('pending', 'dispatched') + GROUP BY session_dispatch.run_id + ) AS session_owner JOIN messages AS candidate INDEXED BY idx_messages_undelivered_direct_run - ON candidate.run_id = actor_owner.run_id AND candidate.to_handle = ? + ON candidate.run_id = session_owner.run_id AND candidate.to_handle = ? WHERE 1 = 1${runExclusion} AND candidate.read = 0 AND candidate.delivered_at IS NULL AND candidate.delivery_contract = 'current_delivery'` ) branchParams.push([ - directHandle, + directOrcaSessionId, directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1 diff --git a/src/main/runtime/orchestration/db/orca-session-address-sql.ts b/src/main/runtime/orchestration/db/orca-session-address-sql.ts new file mode 100644 index 00000000000..83bb7ad2c56 --- /dev/null +++ b/src/main/runtime/orchestration/db/orca-session-address-sql.ts @@ -0,0 +1,10 @@ +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../shared/orca-session-address' + +/** + * The `session:` address of a bare Orca session id column or expression, NULL when it is NULL. + * The only way SQL compares a stored id with a mail address: the id side is formatted, never the + * address side stripped, so a handle or `run:` address can never equal a bare id. + */ +export function orcaSessionAddressSql(orcaSessionIdSql: string): string { + return `('${ORCA_SESSION_ADDRESS_PREFIX}' || ${orcaSessionIdSql})` +} diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index f408026d9db..b511972ad96 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -3,6 +3,7 @@ import { OrchestrationError } from '../../orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../contract-constants' import { isEquivalentPaneKey } from '../pane-key-match' import type { OrchestrationDb } from '../orchestration-db' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' import { addressSpellingsOf, runBoundToCoordinator, @@ -15,8 +16,8 @@ export function bindRun( runId: string coordinatorHandle: string | null coordinatorPaneKey: string | null - /** `session:` when the coordinator is a structured session; see orchestration-actor. */ - coordinatorActor?: string | null + /** The coordinator's bare Orca session id when it is a structured session; see orca-session-address. */ + coordinatorOrcaSessionId?: OrcaSessionId | null takeoverLegacy?: boolean legacyCoordinatorAuthority?: { runId: string @@ -30,7 +31,7 @@ export function bindRun( const coordinator = { terminalHandle: params.coordinatorHandle, paneKey: params.coordinatorPaneKey, - actor: params.coordinatorActor ?? null + orcaSessionId: params.coordinatorOrcaSessionId ?? null } this.db.exec('BEGIN IMMEDIATE') try { @@ -154,19 +155,25 @@ export function bindRun( updated_at = datetime('now') WHERE id = ?` ) - .run(coordinator.terminalHandle, coordinator.paneKey, coordinator.actor, params.runId) + .run( + coordinator.terminalHandle, + coordinator.paneKey, + coordinator.orcaSessionId, + params.runId + ) this.fenceUnacknowledgedMailboxDeliveries(`run:${params.runId}`) if (params.takeoverLegacy || replacesLegacyCoordinator) { this.promoteLegacyCoordinatorMailForTakeover(params.runId, retainedCoordinatorHandle) } - } else if (runCoordinatorKey(run).actor !== coordinator.actor) { - // Same coordinator, so no new consumer: correct an actor a writer without the column left. + } else if (runCoordinatorKey(run).orcaSessionId !== coordinator.orcaSessionId) { + // Same coordinator, so no new consumer: correct an Orca session id a writer without the column left. this.db .prepare( - `UPDATE runs SET coordinator_actor = ?, coordinator_actor_generation = consumer_generation + `UPDATE runs SET coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation WHERE id = ?` ) - .run(coordinator.actor, params.runId) + .run(coordinator.orcaSessionId, params.runId) } this.db.exec('COMMIT') } catch (error) { diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts index 6861d182554..dd6d0a95378 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts @@ -1,16 +1,17 @@ import type { OrchestrationDb } from '../orchestration-db' import { currentRunCoordinatorSessionAddressSql } from './run-coordinator-orca-session' +import { orcaSessionAddressSql } from '../orca-session-address-sql' /** * Mail to an address that is also an active Dispatch assignee in the same Run is that worker's - * mail, not coordinator mail, whether the address is a terminal handle or a session actor. + * mail, not coordinator mail, whether the address is a terminal handle or a session address. */ export function activeDispatchOwnsAddressSql(runIdSql: string, addressSql: string): string { return `EXISTS ( SELECT 1 FROM dispatch_contexts WHERE dispatch_contexts.run_id = ${runIdSql} AND (dispatch_contexts.assignee_handle = ${addressSql} - OR dispatch_contexts.assignee_actor = ${addressSql}) + OR ${orcaSessionAddressSql('dispatch_contexts.assignee_orca_session_id')} = ${addressSql}) AND dispatch_contexts.status IN ('pending', 'dispatched') )` } diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts similarity index 66% rename from src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts rename to src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts index d09a3444192..1aeb1699a23 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-actor-binding.test.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts @@ -5,33 +5,40 @@ import { structuredWorkerProcessIncarnation } from '../../../structured-worker-identity' import { OrchestrationDb } from '../../db' - -const CHAT_X = 'session:1b6f0c3a-7d2e-4a91-8c55-2e9d4b7a0f13' -const CHAT_Y = 'session:6d2a9e41-0c7b-4f38-9a15-b3e8c1d57f20' -const WORKER_SESSION = '9c3e5a17-4b2d-4f60-8e91-0d7a6c2b5e48' -const WORKER_ACTOR = `session:${WORKER_SESSION}` -const OTHER_WORKER_SESSION = '2e8b4d61-5a3c-4e97-b0f2-7c1d9a6e3b54' +import { + formatOrcaSessionAddress, + type OrcaSessionId +} from '../../../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../../../shared/orca-session-address-test-fixture' + +const CHAT_X_ID = testOrcaSessionId('1b6f0c3a-7d2e-4a91-8c55-2e9d4b7a0f13') +const CHAT_X = formatOrcaSessionAddress(CHAT_X_ID) +const CHAT_Y_ID = testOrcaSessionId('6d2a9e41-0c7b-4f38-9a15-b3e8c1d57f20') +const CHAT_Y = formatOrcaSessionAddress(CHAT_Y_ID) +const WORKER_SESSION = testOrcaSessionId('9c3e5a17-4b2d-4f60-8e91-0d7a6c2b5e48') +const WORKER_ADDRESS = formatOrcaSessionAddress(WORKER_SESSION) +const OTHER_WORKER_SESSION = testOrcaSessionId('2e8b4d61-5a3c-4e97-b0f2-7c1d9a6e3b54') const PTY_PANE = 'tab_pty:11111111-1111-4111-8111-111111111111' const OTHER_PANE = 'tab_other:22222222-2222-4222-8222-222222222222' const UNCAPPED = Number.MAX_SAFE_INTEGER -function chat(actor: string) { - return { terminalHandle: null, paneKey: null, actor } +function chat(orcaSessionId: OrcaSessionId) { + return { terminalHandle: null, paneKey: null, orcaSessionId } } -describe('Run binding by orchestration actor', () => { +describe('Run binding by Orca session id', () => { let db: OrchestrationDb afterEach(() => { db?.close() }) - function createChatRun(actor: string, objective = 'chat run') { + function createChatRun(orcaSessionId: OrcaSessionId, objective = 'chat run') { return db.createRun({ objective, coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: actor + coordinatorOrcaSessionId: orcaSessionId }) } @@ -43,11 +50,12 @@ describe('Run binding by orchestration actor', () => { return { terminalHandle: mintStructuredWorkerHandle(), paneKey: mintStructuredWorkerPaneKey(sessionId), - actor: `session:${sessionId}` + orcaSessionId: sessionId, + address: formatOrcaSessionAddress(sessionId) } } - // A binary without the actor column: its bindRun and unbindOtherRunsForPane statements. + // A binary without the Orca session id column: its bindRun and unbindOtherRunsForPane statements. function olderBinaryRebind(runId: string, handle: string, paneKey: string) { db.db .prepare( @@ -75,16 +83,16 @@ describe('Run binding by orchestration actor', () => { return message.id } - it('binds a handle-less session by its actor and remembers the actor as its address', () => { + it('binds a handle-less session by its Orca session id and remembers its session address', () => { db = new OrchestrationDb(':memory:') - const run = createChatRun(CHAT_X) + const run = createChatRun(CHAT_X_ID) expect(db.getRunRaw(run.id)).toMatchObject({ coordinator_handle: null, coordinator_pane_key: null, - coordinator_actor: CHAT_X + coordinator_orca_session_id: CHAT_X_ID }) - expect(db.getCurrentRunForCoordinator(chat(CHAT_X))?.id).toBe(run.id) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(run.id) expect(db.getRunMailboxOwnerIdsForHandle(CHAT_X)).toEqual([run.id]) // Mail to the session's address reaches the Run mailbox, as mail to a coordinator handle does. expect(directMail(run.id, CHAT_X).to_handle).toBe(`run:${run.id}`) @@ -97,8 +105,8 @@ describe('Run binding by orchestration actor', () => { coordinatorHandle: 'term_pty', coordinatorPaneKey: PTY_PANE }) - const yRun = createChatRun(CHAT_Y, 'y') - const xFirst = createChatRun(CHAT_X, 'x first') + const yRun = createChatRun(CHAT_Y_ID, 'y') + const xFirst = createChatRun(CHAT_X_ID, 'x first') const pending = db.insertMessage({ from: 'term_sender', to: 'term_late', @@ -110,68 +118,68 @@ describe('Run binding by orchestration actor', () => { db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(CHAT_X, pending.id) const generation = db.getRunRaw(xFirst.id)?.consumer_generation ?? 0 - const xSecond = createChatRun(CHAT_X, 'x second') + const xSecond = createChatRun(CHAT_X_ID, 'x second') - expect(db.getCurrentRunForCoordinator(chat(CHAT_X))?.id).toBe(xSecond.id) - expect(db.getCurrentRunForCoordinator(chat(CHAT_Y))?.id).toBe(yRun.id) - expect(db.getRunRaw(yRun.id)?.coordinator_actor).toBe(CHAT_Y) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(xSecond.id) + expect(db.getCurrentRunForCoordinator(chat(CHAT_Y_ID))?.id).toBe(yRun.id) + expect(db.getRunRaw(yRun.id)?.coordinator_orca_session_id).toBe(CHAT_Y_ID) expect(db.getRunRaw(ptyRun.id)?.coordinator_pane_key).toBe(PTY_PANE) expect(db.getRunRaw(xFirst.id)).toMatchObject({ coordinator_handle: null, - coordinator_actor: null, + coordinator_orca_session_id: null, consumer_generation: generation + 1 }) // Pending coordinator mail follows the Run, as it does when a pane is unbound. expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${xFirst.id}`) }) - it('stops counting an actor once a binary without the column rebinds the Run to a terminal', () => { + it('stops counting an Orca session id once a binary without the column rebinds the Run to a terminal', () => { db = new OrchestrationDb(':memory:') - const run = createChatRun(CHAT_X) + const run = createChatRun(CHAT_X_ID) olderBinaryRebind(run.id, 'term_taker', PTY_PANE) - expect(db.getCurrentRunForCoordinator(chat(CHAT_X))).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() expect( db.getCurrentRunForCoordinator({ terminalHandle: 'term_taker', paneKey: PTY_PANE, - actor: null + orcaSessionId: null })?.id ).toBe(run.id) - createChatRun(CHAT_X, 'next') + createChatRun(CHAT_X_ID, 'next') expect(db.getRunRaw(run.id)?.coordinator_handle).toBe('term_taker') }) it('does not hand a chat back a Run an older binary rebound and then unbound', () => { db = new OrchestrationDb(':memory:') - const run = createChatRun(CHAT_X) + const run = createChatRun(CHAT_X_ID) olderBinaryRebind(run.id, 'term_taker', PTY_PANE) olderBinaryUnbind(run.id) - // Handle and pane are gone and the actor is still there: the shape of a live chat binding. + // Handle and pane are gone and the id is still there: the shape of a live chat binding. expect(db.getRunRaw(run.id)).toMatchObject({ coordinator_handle: null, coordinator_pane_key: null, - coordinator_actor: CHAT_X + coordinator_orca_session_id: CHAT_X_ID }) - expect(db.getCurrentRunForCoordinator(chat(CHAT_X))).toBeUndefined() - const next = createChatRun(CHAT_X, 'next') - expect(db.getCurrentRunForCoordinator(chat(CHAT_X))?.id).toBe(next.id) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + const next = createChatRun(CHAT_X_ID, 'next') + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))?.id).toBe(next.id) }) - it("stops counting a structured worker's actor once an older binary unbinds its Run", () => { + it("stops counting a structured worker's Orca session id once an older binary unbinds its Run", () => { db = new OrchestrationDb(':memory:') const worker = structuredWorker() const run = db.createRun({ objective: 'worker coordinates', coordinatorHandle: worker.terminalHandle, coordinatorPaneKey: worker.paneKey, - coordinatorActor: worker.actor + coordinatorOrcaSessionId: worker.orcaSessionId }) expect(db.getCurrentRunForCoordinator(worker)?.id).toBe(run.id) olderBinaryUnbind(run.id) expect(db.getCurrentRunForCoordinator(worker)).toBeUndefined() - expect(db.getCurrentRunForCoordinator(chat(worker.actor))).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(worker.orcaSessionId))).toBeUndefined() }) it('remembers a coordinating structured worker at its handle and its session address', () => { @@ -181,17 +189,17 @@ describe('Run binding by orchestration actor', () => { objective: 'worker coordinates', coordinatorHandle: worker.terminalHandle, coordinatorPaneKey: worker.paneKey, - coordinatorActor: worker.actor + coordinatorOrcaSessionId: worker.orcaSessionId }) expect(db.getRunMailboxOwnerIdsForHandle(worker.terminalHandle)).toEqual([run.id]) - expect(db.getRunMailboxOwnerIdsForHandle(WORKER_ACTOR)).toEqual([run.id]) - expect(directMail(run.id, WORKER_ACTOR).to_handle).toBe(`run:${run.id}`) + expect(db.getRunMailboxOwnerIdsForHandle(WORKER_ADDRESS)).toEqual([run.id]) + expect(directMail(run.id, WORKER_ADDRESS).to_handle).toBe(`run:${run.id}`) }) it('hands a Run to a different session like a terminal takeover: fenced, rerouted, remembered', () => { db = new OrchestrationDb(':memory:') - const run = createChatRun(CHAT_X) + const run = createChatRun(CHAT_X_ID) const pending = directMail(run.id, 'term_late') db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(CHAT_X, pending.id) const before = db.getRunRaw(run.id)?.consumer_generation ?? 0 @@ -200,20 +208,20 @@ describe('Run binding by orchestration actor', () => { runId: run.id, coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: CHAT_Y + coordinatorOrcaSessionId: CHAT_Y_ID }) expect(db.getRunRaw(run.id)).toMatchObject({ - coordinator_actor: CHAT_Y, + coordinator_orca_session_id: CHAT_Y_ID, consumer_generation: before + 1 }) - expect(db.getCurrentRunForCoordinator(chat(CHAT_X))).toBeUndefined() - expect(db.getCurrentRunForCoordinator(chat(CHAT_Y))?.id).toBe(run.id) + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(CHAT_Y_ID))?.id).toBe(run.id) expect(db.getMessageById(pending.id)?.to_handle).toBe(`run:${run.id}`) expect(db.getRunMailboxOwnerIdsForHandle(CHAT_Y)).toEqual([run.id]) }) - it('rebinding the same session is not a new consumer, and fills a missing actor in place', () => { + it('rebinding the same session is not a new consumer, and fills a missing Orca session id in place', () => { db = new OrchestrationDb(':memory:') const worker = structuredWorker() const run = db.createRun({ @@ -221,23 +229,25 @@ describe('Run binding by orchestration actor', () => { coordinatorHandle: worker.terminalHandle, coordinatorPaneKey: worker.paneKey }) - // As an older binary writes the row: no actor, and no generation for one. - db.db.prepare('UPDATE runs SET coordinator_actor_generation = NULL WHERE id = ?').run(run.id) + // As an older binary writes the row: no Orca session id, and no generation for one. + db.db + .prepare('UPDATE runs SET coordinator_orca_session_id_generation = NULL WHERE id = ?') + .run(run.id) const before = db.getRunRaw(run.id)?.consumer_generation db.bindRun({ runId: run.id, coordinatorHandle: worker.terminalHandle, coordinatorPaneKey: worker.paneKey, - coordinatorActor: worker.actor + coordinatorOrcaSessionId: worker.orcaSessionId }) expect(db.getRunRaw(run.id)).toMatchObject({ - coordinator_actor: WORKER_ACTOR, + coordinator_orca_session_id: WORKER_SESSION, consumer_generation: before }) - // Written at the current generation, so the filled actor counts on its own. - expect(db.getCurrentRunForCoordinator(chat(WORKER_ACTOR))?.id).toBe(run.id) + // Written at the current generation, so the filled id counts on its own. + expect(db.getCurrentRunForCoordinator(chat(WORKER_SESSION))?.id).toBe(run.id) }) it("reroutes and remembers both of each worker's addresses when one takes a Run from another", () => { @@ -248,16 +258,16 @@ describe('Run binding by orchestration actor', () => { objective: 'first worker coordinates', coordinatorHandle: first.terminalHandle, coordinatorPaneKey: first.paneKey, - coordinatorActor: first.actor + coordinatorOrcaSessionId: first.orcaSessionId }) - const addresses = [first.terminalHandle, first.actor, second.terminalHandle, second.actor] + const addresses = [first.terminalHandle, first.address, second.terminalHandle, second.address] const stray = addresses.map((address) => strayMail(run.id, address)) db.bindRun({ runId: run.id, coordinatorHandle: second.terminalHandle, coordinatorPaneKey: second.paneKey, - coordinatorActor: second.actor + coordinatorOrcaSessionId: second.orcaSessionId }) for (const id of stray) { @@ -274,10 +284,10 @@ describe('Run binding by orchestration actor', () => { const bind = { coordinatorHandle: worker.terminalHandle, coordinatorPaneKey: worker.paneKey, - coordinatorActor: worker.actor + coordinatorOrcaSessionId: worker.orcaSessionId } const last = db.createRun({ objective: 'last', ...bind }) - const stray = [worker.terminalHandle, worker.actor].map((address) => + const stray = [worker.terminalHandle, worker.address].map((address) => strayMail(last.id, address) ) @@ -289,7 +299,7 @@ describe('Run binding by orchestration actor', () => { }) }) -describe('mail owned by an active Dispatch assignee addressed by its actor', () => { +describe('mail owned by an active Dispatch assignee addressed by its session address', () => { let db: OrchestrationDb afterEach(() => { @@ -304,7 +314,7 @@ describe('mail owned by an active Dispatch assignee addressed by its actor', () objective: 'nested', coordinatorHandle: handle, coordinatorPaneKey: paneKey, - coordinatorActor: WORKER_ACTOR + coordinatorOrcaSessionId: WORKER_SESSION }) const dispatch = db.createDispatchContext({ taskId: db.createTask({ runId: run.id, spec: 'own work' }).id, @@ -322,9 +332,14 @@ describe('mail owned by an active Dispatch assignee addressed by its actor', () const { run } = workerCoordinatingItsOwnDispatch() expect( - db.insertMessage({ from: 'term_x', to: WORKER_ACTOR, subject: 's', body: '', runId: run.id }) - .to_handle - ).toBe(WORKER_ACTOR) + db.insertMessage({ + from: 'term_x', + to: WORKER_ADDRESS, + subject: 's', + body: '', + runId: run.id + }).to_handle + ).toBe(WORKER_ADDRESS) }) it('leaves that mail in place when the Run is rebound', () => { @@ -332,15 +347,15 @@ describe('mail owned by an active Dispatch assignee addressed by its actor', () const { run } = workerCoordinatingItsOwnDispatch() const mail = db.insertMessage({ from: 'term_x', - to: WORKER_ACTOR, + to: WORKER_ADDRESS, subject: 's', body: '', runId: run.id }) - db.routeAllUnreadDirectMessagesToRunMailbox(run.id, WORKER_ACTOR) + db.routeAllUnreadDirectMessagesToRunMailbox(run.id, WORKER_ADDRESS) - expect(db.getMessageById(mail.id)?.to_handle).toBe(WORKER_ACTOR) + expect(db.getMessageById(mail.id)?.to_handle).toBe(WORKER_ADDRESS) }) it("sweeps the session's stray mail from another Run into its Dispatch mailbox", () => { @@ -353,13 +368,13 @@ describe('mail owned by an active Dispatch assignee addressed by its actor', () }) const stray = db.insertMessage({ from: 'term_x', - to: WORKER_ACTOR, + to: WORKER_ADDRESS, subject: 's', body: '', runId: run.id }) - const routed = db.routeForeignDirectMessagesToOwnedMailboxes(WORKER_ACTOR) + const routed = db.routeForeignDirectMessagesToOwnedMailboxes(WORKER_ADDRESS) expect(routed.routedCount).toBe(1) expect(db.getMessageById(stray.id)?.to_handle).toBe(`dispatch:${dispatch.id}`) @@ -391,32 +406,32 @@ describe('stray mail to a session address that is only an assignee', () => { // The worker coordinates nothing, so no address cache entry can claim this mail. const stray = db.insertMessage({ from: 'term_c', - to: WORKER_ACTOR, + to: WORKER_ADDRESS, subject: 's', body: '', runId: run.id }) - expect(db.getMessageById(stray.id)?.to_handle).toBe(WORKER_ACTOR) + expect(db.getMessageById(stray.id)?.to_handle).toBe(WORKER_ADDRESS) - expect(db.routeForeignDirectMessagesToOwnedMailboxes(WORKER_ACTOR).routedCount).toBe(1) + expect(db.routeForeignDirectMessagesToOwnedMailboxes(WORKER_ADDRESS).routedCount).toBe(1) expect(db.getMessageById(stray.id)?.to_handle).toBe(`dispatch:${dispatch.id}`) }) }) -describe('Dispatch actors recorded by every writer', () => { +describe('Dispatch Orca session ids recorded by every writer', () => { let db: OrchestrationDb afterEach(() => { db?.close() }) - it('records the assignee actor from a structured incarnation and a creator actor', () => { + it('records the assignee Orca session id from a structured incarnation and a creator one', () => { db = new OrchestrationDb(':memory:') const run = db.createRun({ objective: 'r', coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: CHAT_X + coordinatorOrcaSessionId: CHAT_X_ID }) const handle = mintStructuredWorkerHandle() const assigned = db.createDispatchContext({ @@ -424,7 +439,7 @@ describe('Dispatch actors recorded by every writer', () => { assigneeHandle: handle, assigneePaneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), - creator: { kind: 'actor', actor: CHAT_X }, + creator: { kind: 'session', orcaSessionId: CHAT_X_ID }, maxDepth: UNCAPPED }) const pty = db.createDispatchContext({ @@ -437,12 +452,40 @@ describe('Dispatch actors recorded by every writer', () => { }) expect(assigned).toMatchObject({ - assignee_actor: WORKER_ACTOR, + assignee_orca_session_id: WORKER_SESSION, creator_handle: null, creator_pane_key: null, - creator_actor: CHAT_X + creator_orca_session_id: CHAT_X_ID + }) + expect(pty).toMatchObject({ assignee_orca_session_id: null, creator_orca_session_id: null }) + }) + + it('nests under the Dispatch a handle-less creator is assigned by its Orca session id', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'r', + coordinatorHandle: 'term_c', + coordinatorPaneKey: OTHER_PANE + }) + const parent = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'parent' }).id, + assigneeHandle: mintStructuredWorkerHandle(), + assigneePaneKey: mintStructuredWorkerPaneKey(WORKER_SESSION), + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION), + creator: { kind: 'system' }, + maxDepth: UNCAPPED + }) + + const child = db.createDispatchContext({ + taskId: db.createTask({ runId: run.id, spec: 'child' }).id, + assigneeHandle: 'term_child', + assigneePaneKey: PTY_PANE, + processIncarnation: 'pty_proc:2', + creator: { kind: 'session', orcaSessionId: WORKER_SESSION }, + maxDepth: UNCAPPED }) - expect(pty).toMatchObject({ assignee_actor: null, creator_actor: null }) + + expect(child).toMatchObject({ creator_dispatch_id: parent.id, depth: parent.depth + 1 }) }) it('records the starting creator and the attached assignee of a worker-start', () => { @@ -451,16 +494,19 @@ describe('Dispatch actors recorded by every writer', () => { objective: 'r', coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: CHAT_X + coordinatorOrcaSessionId: CHAT_X_ID }) const started = db.createStartingWorkerDispatch({ - creator: { kind: 'actor', actor: CHAT_X }, + creator: { kind: 'session', orcaSessionId: CHAT_X_ID }, maxDepth: UNCAPPED, taskSpec: 'work', taskRunId: run.id, startOptions: {} }) - expect(started.dispatch).toMatchObject({ creator_actor: CHAT_X, assignee_actor: null }) + expect(started.dispatch).toMatchObject({ + creator_orca_session_id: CHAT_X_ID, + assignee_orca_session_id: null + }) const handle = mintStructuredWorkerHandle() db.prepareStartingWorkerAuthority({ @@ -473,6 +519,8 @@ describe('Dispatch actors recorded by every writer', () => { setupState: 'not_applicable' }) - expect(db.getDispatchContextById(started.dispatch.id)?.assignee_actor).toBe(WORKER_ACTOR) + expect(db.getDispatchContextById(started.dispatch.id)?.assignee_orca_session_id).toBe( + WORKER_SESSION + ) }) }) diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts index 2ed9d9809a2..007e979df4d 100644 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts @@ -1,4 +1,5 @@ -import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../../shared/orca-session-address' +import { orcaSessionAddressSql } from '../orca-session-address-sql' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' import type { RunRow } from '../../types' type RunCoordinatorOrcaSessionFields = Pick< @@ -13,7 +14,7 @@ type RunCoordinatorOrcaSessionFields = Pick< */ export function currentRunCoordinatorOrcaSessionId( run: RunCoordinatorOrcaSessionFields -): string | null { +): OrcaSessionId | null { return run.coordinator_orca_session_id_generation === run.consumer_generation ? run.coordinator_orca_session_id : null @@ -27,5 +28,5 @@ export function currentRunCoordinatorOrcaSessionIdSql(row: string): string { /** The coordinator's `session:` address in SQL; NULL when it has no current Orca session id. */ export function currentRunCoordinatorSessionAddressSql(row: string): string { - return `('${ORCA_SESSION_ADDRESS_PREFIX}' || ${currentRunCoordinatorOrcaSessionIdSql(row)})` + return orcaSessionAddressSql(currentRunCoordinatorOrcaSessionIdSql(row)) } diff --git a/src/main/runtime/orchestration/db/runs/run-create.ts b/src/main/runtime/orchestration/db/runs/run-create.ts index 73698f268a2..8250f44b58b 100644 --- a/src/main/runtime/orchestration/db/runs/run-create.ts +++ b/src/main/runtime/orchestration/db/runs/run-create.ts @@ -1,6 +1,7 @@ import type { RunRow } from '../../types' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' +import type { OrcaSessionId } from '../../../../../shared/orca-session-address' import { addressSpellingsOf } from '../../orchestration-caller-identity' // ── Runs ── @@ -11,14 +12,14 @@ export function createRun( objective: string coordinatorHandle: string | null coordinatorPaneKey: string | null - /** `session:` when the coordinator is a structured session; see orchestration-actor. */ - coordinatorActor?: string | null + /** The coordinator's bare Orca session id when it is a structured session; see orca-session-address. */ + coordinatorOrcaSessionId?: OrcaSessionId | null } ): RunRow { const coordinator = { terminalHandle: params.coordinatorHandle, paneKey: params.coordinatorPaneKey, - actor: params.coordinatorActor ?? null + orcaSessionId: params.coordinatorOrcaSessionId ?? null } const id = generateId('run') this.db.exec('BEGIN IMMEDIATE') @@ -27,11 +28,17 @@ export function createRun( this.db .prepare( `INSERT INTO runs ( - id, objective, coordinator_handle, coordinator_pane_key, coordinator_actor, - coordinator_actor_generation, consumer_generation, legacy + id, objective, coordinator_handle, coordinator_pane_key, coordinator_orca_session_id, + coordinator_orca_session_id_generation, consumer_generation, legacy ) VALUES (?, ?, ?, ?, ?, 1, 1, 0)` ) - .run(id, params.objective, coordinator.terminalHandle, coordinator.paneKey, coordinator.actor) + .run( + id, + params.objective, + coordinator.terminalHandle, + coordinator.paneKey, + coordinator.orcaSessionId + ) for (const address of addressSpellingsOf(coordinator)) { this.rememberRunCoordinatorHandle(id, address) } diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index b634f110b37..7109e1594aa 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -28,11 +28,11 @@ const RUNS_BOUND_TO_PANE_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs WHERE coordinator_pane_key IS NOT NULL AND legacy = 0 AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ? ORDER BY rowid` -// Why: one statement so pane and actor matches keep a single rowid order; the JS predicate decides. +// Why: one statement so pane and Orca session id matches keep a single rowid order; the JS predicate decides. const RUNS_BOUND_TO_COORDINATOR_SQL = `SELECT ${RUN_COLUMN_LIST} FROM runs WHERE legacy = 0 AND ( (coordinator_pane_key IS NOT NULL AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ?) - OR coordinator_actor = ? + OR coordinator_orca_session_id = ? ) ORDER BY rowid` @@ -143,16 +143,16 @@ export function getCurrentRunForCoordinator( return run ? exposeRunTimestamps(run) : undefined } -/** Runs bound to this caller by pane or by actor; a caller without an actor matches as before. */ +/** Runs bound to this caller by pane or by Orca session id; a caller without one matches as before. */ export function runsBoundToCoordinator( this: OrchestrationDb, caller: OrchestrationCoordinatorKey ): RunRow[] { - if (caller.actor === null) { + if (caller.orcaSessionId === null) { return caller.paneKey === null ? [] : this.runsBoundToPane(caller.paneKey) } const suffix = caller.paneKey === null ? null : paneKeyMatchSuffix(caller.paneKey) - const rows = this.db.prepare(RUNS_BOUND_TO_COORDINATOR_SQL).all(suffix, caller.actor) + const rows = this.db.prepare(RUNS_BOUND_TO_COORDINATOR_SQL).all(suffix, caller.orcaSessionId) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: SELECT * over this table returns the row shape its schema and row type define, like every row cast in db/. return (rows as RunRow[]).filter((run) => runBoundToCoordinator(run, caller)) } diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts index d31f5cc241f..273d7a09aca 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts @@ -96,8 +96,10 @@ describe('structured worker Orca session id backfill', () => { processIncarnation: structuredWorkerProcessIncarnation(SESSION_B), ownership: 'owned' }) - // Rows a writer without the actor column left; a current writer records the incarnation's actor. - db.db.exec('UPDATE dispatch_contexts SET assignee_actor = NULL, creator_actor = NULL') + // Rows a writer without the column left; a current writer records the incarnation's Orca session id. + db.db.exec( + 'UPDATE dispatch_contexts SET assignee_orca_session_id = NULL, creator_orca_session_id = NULL' + ) backfillStructuredWorkerOrcaSessionIds(db.db) diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts index d052b0a9a1c..bc7b2c0cc0c 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-authority.ts @@ -2,7 +2,7 @@ import { randomBytes } from 'node:crypto' import { OrchestrationError } from '../../orchestration-error' import { hashDispatchCapability } from '../dispatch-capability-hash' import type { OrchestrationDb } from '../orchestration-db' -import { structuredWorkerActorForIncarnation } from '../../../structured-worker-identity' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' export function prepareStartingWorkerAuthority( this: OrchestrationDb, @@ -64,7 +64,7 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, - structuredWorkerActorForIncarnation(params.processIncarnation), + structuredWorkerOrcaSessionIdForIncarnation(params.processIncarnation), params.processIncarnation, params.hostScope ?? null, hashDispatchCapability(capability), diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index 4fd9df16eb2..490cfbb1138 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts @@ -1,6 +1,6 @@ import type { WorkerDispatchRow } from '../../types' import type { OrchestrationDb } from '../orchestration-db' -import { structuredWorkerActorForIncarnation } from '../../../structured-worker-identity' +import { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' /** * A start that dies before `prepareStartingWorkerAuthority` never filled the Dispatch context in, @@ -29,7 +29,7 @@ export function recordFailedStartDispatchIdentity( .run( resource.terminal_handle, resource.pane_key, - structuredWorkerActorForIncarnation(resource.process_incarnation), + structuredWorkerOrcaSessionIdForIncarnation(resource.process_incarnation), resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/orchestration-caller-identity.ts b/src/main/runtime/orchestration/orchestration-caller-identity.ts index 72b463b34b1..7770d3a8331 100644 --- a/src/main/runtime/orchestration/orchestration-caller-identity.ts +++ b/src/main/runtime/orchestration/orchestration-caller-identity.ts @@ -1,61 +1,66 @@ import type { RunRow } from './types' import { isEquivalentPaneKey } from './db/pane-key-match' -import { currentRunCoordinatorActor } from './db/runs/run-coordinator-actor' +import { currentRunCoordinatorOrcaSessionId } from './db/runs/run-coordinator-orca-session' +import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/orca-session-address' /** * Who an orchestration caller is, as Run binding and mail routing match it. * - * A PTY agent is its terminal: a handle and a pane key, no actor. An agent that is a structured - * session is its actor (`session:`); a structured worker also has the handle and pane key it - * was minted, and an ordinary chat has neither. Methods pass this through whole and never branch - * on which fields are set; the lookups below own that. + * A PTY agent is its terminal: a handle and a pane key, no Orca session id. An agent that is a + * structured session is its Orca session id, addressed as `session:`; a structured worker also + * has the handle and pane key it was minted, and an ordinary chat has neither. Methods pass this + * through whole and never branch on which fields are set; the lookups below own that. */ export type OrchestrationCallerIdentity = Readonly<{ - /** Mailbox address the caller sends from and reads: its terminal handle, else its actor. */ + /** Mailbox address the caller sends from and reads: its terminal handle, else its session address. */ address: string terminalHandle: string | null paneKey: string | null - actor: string | null + /** The bare Orca session id the caller is addressed by; mail spells it `session:`. */ + orcaSessionId: OrcaSessionId | null }> /** The part of a caller a Run binding stores and matches. */ export type OrchestrationCoordinatorKey = Pick< OrchestrationCallerIdentity, - 'terminalHandle' | 'paneKey' | 'actor' + 'terminalHandle' | 'paneKey' | 'orcaSessionId' > /** A caller the dispatch entry resolved from the Orca session id in its injected environment. */ export type OrchestrationSessionCaller = OrchestrationCallerIdentity & Readonly<{ - actor: string - sessionId: string + orcaSessionId: OrcaSessionId + /** The session record the request came from. */ + sessionId: OrcaSessionId /** Where the session runs, from its record; `worker-start --worktree current` places here. */ workspaceId: string }> -/** A caller with neither a pane nor an actor can never be bound to a Run. */ +/** A caller with neither a pane nor an Orca session id can never be bound to a Run. */ export function hasRunBindingKey(caller: OrchestrationCoordinatorKey): boolean { - return caller.paneKey !== null || caller.actor !== null + return caller.paneKey !== null || caller.orcaSessionId !== null } /** * Every address one party is reachable at. A structured worker has two, its handle and its session - * actor, so every consumer that remembers, reroutes or compares a party's mail takes this set. + * address, so every consumer that remembers, reroutes or compares a party's mail takes this set. */ export function addressSpellingsOf( - party: Pick + party: Pick ): string[] { - return [...new Set([party.terminalHandle, party.actor])].filter( + const sessionAddress = + party.orcaSessionId === null ? null : formatOrcaSessionAddress(party.orcaSessionId) + return [...new Set([party.terminalHandle, sessionAddress])].filter( (address): address is string => address !== null ) } -/** Who a Run's binding names now; an actor an older binding left behind is not part of it. */ +/** Who a Run's binding names now; an Orca session id an older binding left behind is not part of it. */ export function runCoordinatorKey(run: RunRow): OrchestrationCoordinatorKey { return { terminalHandle: run.coordinator_handle, paneKey: run.coordinator_pane_key, - actor: currentRunCoordinatorActor(run) + orcaSessionId: currentRunCoordinatorOrcaSessionId(run) } } @@ -67,5 +72,8 @@ export function runBoundToCoordinator(run: RunRow, caller: OrchestrationCoordina ) { return true } - return caller.actor !== null && currentRunCoordinatorActor(run) === caller.actor + return ( + caller.orcaSessionId !== null && + currentRunCoordinatorOrcaSessionId(run) === caller.orcaSessionId + ) } diff --git a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts index e9fb9ae9b39..b721dcc166e 100644 --- a/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts @@ -11,6 +11,7 @@ import { import { OrchestrationDb } from './db' import { SCHEMA_VERSION } from './db/contract-constants' import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { RUN_PANE_KEY_MATCH_SUFFIX_SQL } from './db/pane-key-match' import { currentRunCoordinatorOrcaSessionId, @@ -18,8 +19,8 @@ import { } from './db/runs/run-coordinator-orca-session' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' -const SESSION_ID = '5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11' -const CHAT_SESSION_ID = '9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93' +const SESSION_ID = testOrcaSessionId('5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11') +const CHAT_SESSION_ID = testOrcaSessionId('9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93') const CHAT_SESSION_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) const ORCA_SESSION_ID_COLUMNS = [ 'assignee_orca_session_id', diff --git a/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts b/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts index f93e1e4a4b5..4dd07052d6f 100644 --- a/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts +++ b/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts @@ -6,6 +6,7 @@ import { formatOrcaSessionAddress, parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, @@ -14,9 +15,9 @@ import { import { OrchestrationDb } from './db' import { backfillStructuredWorkerOrcaSessionIds } from './db/schema/structured-worker-orca-session-backfill' -const CHAT_SESSION_ID = '3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14' +const CHAT_SESSION_ID = testOrcaSessionId('3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14') const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) -const WORKER_SESSION_ID = '4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25' +const WORKER_SESSION_ID = testOrcaSessionId('4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25') const WORKER_ADDRESS = formatOrcaSessionAddress(WORKER_SESSION_ID) const PTY_PANE = 'tab_pty:66666666-6666-4666-8666-666666666666' diff --git a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts index 2c091318b5b..56fa2d6bb9d 100644 --- a/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts +++ b/src/main/runtime/orchestration/structured-worker-group-addressing.test.ts @@ -270,7 +270,12 @@ describe('sendGroupMessage actually composes structured workers in', () => { db: db as never, from: 'term_sender', groupAddress: '@codex', - sender: { address: 'term_sender', terminalHandle: 'term_sender', paneKey: null, actor: null }, + sender: { + address: 'term_sender', + terminalHandle: 'term_sender', + paneKey: null, + orcaSessionId: null + }, senderPaneKey: undefined, senderRunId: 'run_1', explicitRunId: undefined, diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index d00ea480c7d..cd2b12a7da2 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -1,4 +1,5 @@ import type { TerminalExitCause } from '../../../shared/terminal-exit-cause' +import type { OrcaSessionId } from '../../../shared/orca-session-address' export const MESSAGE_TYPES = [ 'status', 'dispatch', @@ -47,7 +48,7 @@ export type RunRow = { coordinator_handle: string | null coordinator_pane_key: string | null /** Bare Orca session id the coordinator is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - coordinator_orca_session_id: string | null + coordinator_orca_session_id: OrcaSessionId | null /** The consumer_generation the id was written at; see currentRunCoordinatorOrcaSessionId. */ coordinator_orca_session_id_generation: number | null consumer_generation: number @@ -283,7 +284,7 @@ export type DispatchContextRow = { assignee_handle: string | null assignee_pane_key: string | null /** Bare Orca session id the assignee is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - assignee_orca_session_id: string | null + assignee_orca_session_id: OrcaSessionId | null capability_hash: string | null process_incarnation: string | null capability_revoked_at: string | null @@ -294,7 +295,7 @@ export type DispatchContextRow = { creator_handle: string | null creator_pane_key: string | null /** Bare Orca session id the creator is addressed by, when it has one (today only structured sessions); a `/clear`ed chat's lineage root. */ - creator_orca_session_id: string | null + creator_orca_session_id: OrcaSessionId | null host_scope: string | null status: DispatchStatus failure_count: number diff --git a/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts b/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts index abc38647683..d8bf3e25b53 100644 --- a/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts +++ b/src/main/runtime/rpc/dispatcher-unary-method-invocation.ts @@ -83,7 +83,7 @@ export async function invokeDispatcherUnaryMethod({ effectiveParams, invoke, legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint, - context.orchestrationCaller?.actor + context.orchestrationCaller?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) return result diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts index 0e7bbff5619..269c1f3ff4c 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts @@ -26,8 +26,10 @@ export function resolveDispatchCreator( paneKey: null }) if (caller.terminalHandle === null) { - // A handle-less session: its actor is its whole identity. - return caller.actor ? { kind: 'actor', actor: caller.actor } : { kind: 'system' } + // A handle-less session: its Orca session id is its whole identity. + return caller.orcaSessionId + ? { kind: 'session', orcaSessionId: caller.orcaSessionId } + : { kind: 'system' } } const authority = runtime.getOrchestrationDispatchAuthority?.(caller.terminalHandle) return { @@ -39,6 +41,6 @@ export function resolveDispatchCreator( runtime.getTerminalPaneKey(caller.terminalHandle) ?? undefined, processIncarnation: authority?.processIncarnation ?? undefined, - ...(caller.actor ? { actor: caller.actor } : {}) + ...(caller.orcaSessionId ? { orcaSessionId: caller.orcaSessionId } : {}) } } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts index 49afdfe119a..93d6f7bdda9 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { exposeRun } from './run-receipt' import type { RunRow } from '../../../../orchestration/types' +import { testOrcaSessionId } from '../../../../../../shared/orca-session-address-test-fixture' // Why: typecheck cannot see the strip because the RPC return types are loose. const RUN_ROW: RunRow = { @@ -9,7 +10,7 @@ const RUN_ROW: RunRow = { home_database: '/tmp/orca/orchestration.db', coordinator_handle: 'term_coord', coordinator_pane_key: 'tab_coord:11111111-1111-4111-8111-111111111111', - coordinator_orca_session_id: '22222222-2222-4222-8222-222222222222', + coordinator_orca_session_id: testOrcaSessionId('22222222-2222-4222-8222-222222222222'), coordinator_orca_session_id_generation: 3, consumer_generation: 3, legacy: 0, diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts index f36c941f6ef..a75f566f028 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts @@ -12,7 +12,7 @@ import { type OrchestrationSessionCaller } from '../../../../orchestration/orchestration-caller-identity' import { resolveStructuredWorkerIdentity } from '../../../../structured-worker-authority' -import { formatOrchestrationActor } from '../../../../../../shared/orchestration-actor' +import { isOrcaSessionId } from '../../../../../../shared/orca-session-address' export type RunScopeParams = { runId?: string @@ -48,7 +48,7 @@ export function assertCallerHandleMatchesEvidence( /** * The caller as Run binding and mail routing see it. A session the dispatch entry resolved is the * caller outright; otherwise it is the declared terminal handle and the pane it resolved to, plus - * the actor a structured worker's handle was minted for. + * the Orca session id a structured worker's handle was minted for. */ export function orchestrationCallerIdentity( runtime: OrcaRuntimeService, @@ -66,7 +66,7 @@ export function orchestrationCallerIdentity( address: caller.handle, terminalHandle: caller.handle, paneKey: caller.paneKey ?? null, - actor: worker ? formatOrchestrationActor({ kind: 'session', id: worker.sessionId }) : null + orcaSessionId: worker && isOrcaSessionId(worker.sessionId) ? worker.sessionId : null } } diff --git a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts index 26fb51641a6..f074b5eba03 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/runs.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/runs.ts @@ -27,7 +27,7 @@ export const ORCHESTRATION_RUN_METHODS = [ objective: params.objective, coordinatorHandle: caller.terminalHandle, coordinatorPaneKey: caller.paneKey, - coordinatorActor: caller.actor + coordinatorOrcaSessionId: caller.orcaSessionId }) runtime.cancelMessageWaiters(params.from) if (priorRun) { @@ -75,7 +75,7 @@ export const ORCHESTRATION_RUN_METHODS = [ runId: params.id, coordinatorHandle: caller.terminalHandle, coordinatorPaneKey: caller.paneKey, - coordinatorActor: caller.actor, + coordinatorOrcaSessionId: caller.orcaSessionId, takeoverLegacy: params.takeoverLegacy, legacyCoordinatorAuthority }) diff --git a/src/main/runtime/rpc/orchestration-mutation-executor.ts b/src/main/runtime/rpc/orchestration-mutation-executor.ts index 719dcddee01..010541357f5 100644 --- a/src/main/runtime/rpc/orchestration-mutation-executor.ts +++ b/src/main/runtime/rpc/orchestration-mutation-executor.ts @@ -4,6 +4,7 @@ import { isTerminalPromptMutation } from '../../../shared/orchestration-rpc-contract' import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrcaSessionId } from '../../../shared/orca-session-address' import { OrchestrationError } from '../orchestration/orchestration-error' import type { RpcRequest } from './core' import { @@ -54,8 +55,8 @@ export class OrchestrationMutationExecutor { params: unknown, invoke: (mutation?: DurableMutationInvocation) => unknown, callerFingerprintOverride?: string, - /** The resolved session actor; it joins the payload so another caller cannot replay it. */ - callerActor?: string + /** The resolved session's Orca session id; it joins the payload so another caller cannot replay it. */ + callerOrcaSessionId?: OrcaSessionId ): Promise { const requestId = request.orchestrationRequestId if (!requestId || !isDurableMutation(request.method, params)) { @@ -63,7 +64,7 @@ export class OrchestrationMutationExecutor { } const callerFingerprint = callerFingerprintOverride ?? this.getLocalAuthenticatedCallerFingerprint() - const stableParams = replayStableCallerParams(this.runtime, params, callerActor) + const stableParams = replayStableCallerParams(this.runtime, params, callerOrcaSessionId) const basePayloadHash = hashCanonical({ method: request.method, params: stableParams }) const key = `${callerFingerprint}:${requestId}` const db = this.runtime.getOrchestrationDb() diff --git a/src/main/runtime/rpc/orchestration-mutation-receipt.ts b/src/main/runtime/rpc/orchestration-mutation-receipt.ts index a535618c306..1d94723550b 100644 --- a/src/main/runtime/rpc/orchestration-mutation-receipt.ts +++ b/src/main/runtime/rpc/orchestration-mutation-receipt.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto' import { isTerminalPromptMutation } from '../../../shared/orchestration-rpc-contract' import { parsePaneKey } from '../../../shared/stable-pane-id' import type { OrcaRuntimeService } from '../orca-runtime' +import type { OrcaSessionId } from '../../../shared/orca-session-address' export const EFFECT_FREE_WORKER_DONE_CHECKPOINT = JSON.stringify({ pending: { effectFree: 'worker_done' } @@ -13,20 +14,20 @@ export type MutationReplayNudge = | { kind: 'messages'; targets: { to: string; type: string }[] } | { kind: 'federation'; runId?: string } -const CALLER_ACTOR_KEY = '__orcaCallerActor' +const CALLER_ORCA_SESSION_ID_KEY = '__orcaCallerOrcaSessionId' export function replayStableCallerParams( runtime: OrcaRuntimeService, params: unknown, - callerActor?: string + callerOrcaSessionId?: OrcaSessionId ): unknown { if (!params || typeof params !== 'object' || Array.isArray(params)) { return params } const source = params as Record // Absent for terminal callers, so their payload hashes are unchanged. - const result: Record = callerActor - ? { ...source, [CALLER_ACTOR_KEY]: callerActor } + const result: Record = callerOrcaSessionId + ? { ...source, [CALLER_ORCA_SESSION_ID_KEY]: callerOrcaSessionId } : { ...source } delete result.waitSubmitMs for (const property of ['from', 'callerTerminalHandle', 'terminal'] as const) { diff --git a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts index 71996185d89..396b0b2bec7 100644 --- a/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts +++ b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts @@ -6,6 +6,8 @@ import { } from '../../../shared/agent-session-record.test-fixture' import type { OrchestrationCompatibilityEvidence } from '../../../shared/orchestration-compatibility-evidence' import { ORCHESTRATION_CONTRACT_VERSION } from '../../../shared/protocol-version' +import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' +import { testOrcaSessionId } from '../../../shared/orca-session-address-test-fixture' import { OrcaRuntimeService } from '../orca-runtime' import { OrchestrationDb } from '../orchestration/db' import { structuredWorkerIdentities } from '../structured-worker-identity' @@ -13,10 +15,10 @@ import type { RpcRequest, RpcResponse } from './core' import { RpcDispatcher } from './dispatcher' import { ORCHESTRATION_METHODS } from './methods/orchestration' -export const SESSION_X = '4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37' -export const SESSION_Y = '7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64' -export const ACTOR_X = `session:${SESSION_X}` -export const ACTOR_Y = `session:${SESSION_Y}` +export const SESSION_X = testOrcaSessionId('4a1f6c2e-8b3d-4e7a-9c15-0d2b6e8f1a37') +export const SESSION_Y = testOrcaSessionId('7e3b9d15-2c4a-4f86-a0b1-5c9e2d7f3b64') +export const ADDRESS_X = formatOrcaSessionAddress(SESSION_X) +export const ADDRESS_Y = formatOrcaSessionAddress(SESSION_Y) export const PROVIDER_ID_X = 'c0ffee11-2233-4455-8677-8899aabbccdd' export const WORKSPACE_X = 'repo_1::/work/tree-x' export const WORKER_HANDLE = 'term_worker' diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index c23bd9087bc..6037e3d2384 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -15,7 +15,7 @@ import { structuredWorkerProcessIncarnation } from '../structured-worker-identity' import { - ACTOR_X, + ADDRESS_X, createSessionCallerHarness, orchestrationRequest, PROVIDER_ID_X, @@ -38,7 +38,7 @@ vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry' // Fields that name a party. They name the caller only in the methods ORCHESTRATION_CALLER_PARAM lists. const PARTY_NAMING_FIELDS = ['from', 'terminal', 'callerTerminalHandle'] as const -// Methods with such a field that never reads it as the caller's identity, for any actor. +// Methods with such a field that never reads it as the caller's identity, for any caller. const NAMES_A_PARTY_BUT_NOT_THE_CALLER: Readonly> = { 'orchestration.run': 'retired; refused before any handler', 'orchestration.runShow': 'reads a Run by id; `from` is unused', @@ -189,8 +189,12 @@ describe('orchestration session callers at the dispatch entry', () => { ) expect(local).toMatchObject({ ok: true, result: { run: { objective: 'o' } } }) expect( - h.db.getCurrentRunForCoordinator({ terminalHandle: null, paneKey: null, actor: ACTOR_X }) - ).toMatchObject({ objective: 'o', coordinator_actor: ACTOR_X }) + h.db.getCurrentRunForCoordinator({ + terminalHandle: null, + paneKey: null, + orcaSessionId: SESSION_X + }) + ).toMatchObject({ objective: 'o', coordinator_orca_session_id: SESSION_X }) }) describe('refuses a session that cannot act, before any destructive or consuming lookup', () => { @@ -199,11 +203,11 @@ describe('orchestration session callers at the dispatch entry', () => { objective: 'x', coordinatorHandle: null, coordinatorPaneKey: null, - coordinatorActor: ACTOR_X + coordinatorOrcaSessionId: SESSION_X }) const message = h.db.insertMessage({ from: 'term_worker', - to: ACTOR_X, + to: ADDRESS_X, subject: 'pending', body: '', runId: run.id @@ -323,7 +327,9 @@ describe('orchestration session callers at the dispatch entry', () => { ok: false, error: { code: CODES.notLive, message: expect.stringContaining('no longer has') } }) - expect(h.db.listRuns().runs.filter((row) => row.coordinator_actor !== null)).toEqual([]) + expect( + h.db.listRuns().runs.filter((row) => row.coordinator_orca_session_id !== null) + ).toEqual([]) }) it('an agent-session host that cannot be brought up to verify it', async () => { @@ -362,7 +368,7 @@ describe('orchestration session callers at the dispatch entry', () => { expect(response).toMatchObject({ ok: false, error: { code: 'consumer_fenced' } }) }) - it.each([ACTOR_X, SESSION_X])('accepts the session named as %s', async (declared) => { + it.each([ADDRESS_X, SESSION_X])('accepts the session named as %s', async (declared) => { const run = resultOf( await h.dispatch( orchestrationRequest( @@ -385,6 +391,6 @@ describe('orchestration session callers at the dispatch entry', () => { const run = resultOf(await h.dispatch(request)).run expect(run).toMatchObject({ coordinator_handle: 'term_worker' }) - expect(h.db.getRunRaw(idOf(run))?.coordinator_actor).toBeNull() + expect(h.db.getRunRaw(idOf(run))?.coordinator_orca_session_id).toBeNull() }) }) diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index fc00b2f0c74..b579e52c88f 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -9,16 +9,13 @@ * - Same host only. A paired client, an SSH environment or a WSL shell is another host, where * "same machine, same user" does not hold, so a session claim from one is refused. * - The Orca id, never the provider's: that one rotates on `/clear`. - * - A live lease under either owner (native chat or terminal view), so a handoff keeps the actor. + * - A live lease under either owner (native chat or terminal view), so a handoff keeps the identity. * - The session wins over any declared caller: a declared handle must name this same session, and * a structured worker's session id maps to the handle and pane it was minted. */ import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - formatOrchestrationActor, - sessionOrchestrationActor -} from '../../../shared/orchestration-actor' +import { formatOrcaSessionAddress, isOrcaSessionId } from '../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' import { getStructuredAgentSessionHost } from '../../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrcaRuntimeService } from '../orca-runtime' @@ -39,7 +36,7 @@ type CallerParam = 'from' | 'terminal' | 'callerTerminalHandle' /** * Every method that consults caller identity, and the param it names its caller in. This list is * the contract: a method that starts reading caller identity is added here with its own test. - * Methods not listed carry no caller identity for any actor; a session claim on them is still + * Methods not listed carry no caller identity for any caller; a session claim on them is still * validated, then they run exactly as they do for a terminal caller. */ export const ORCHESTRATION_CALLER_PARAM: Readonly> = { @@ -102,15 +99,14 @@ export async function resolveOrchestrationSessionCaller( `This command ran in ${evidence.host.kind === 'ssh' ? 'an SSH' : 'a WSL'} environment, and an agent session id identifies a caller only on the host that runs that session.` ) } - const actor = sessionOrchestrationActor(typeof claimed === 'string' ? claimed : '') - if (!actor) { + if (typeof claimed !== 'string' || !isOrcaSessionId(claimed)) { throw new OrchestrationError( CODES.unknown, 'The caller named an agent session id that is not an Orca session id. No effects were applied.', NO_EFFECTS ) } - const sessionId = actor.id + const sessionId = claimed const record = await readSessionRecord(runtime, sessionId) assertSessionCanAct(sessionId, record) const db = runtime.getOrchestrationDb() @@ -126,8 +122,8 @@ export async function resolveOrchestrationSessionCaller( const terminalHandle = worker?.handle ?? null const caller: OrchestrationSessionCaller = Object.freeze({ sessionId, - actor: formatOrchestrationActor(actor), - address: terminalHandle ?? formatOrchestrationActor(actor), + orcaSessionId: sessionId, + address: terminalHandle ?? formatOrcaSessionAddress(sessionId), terminalHandle, paneKey: worker?.paneKey ?? null, workspaceId: record.location.workspaceId diff --git a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts index fda4a4cab19..1a1ecb68d67 100644 --- a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -6,8 +6,8 @@ import { structuredWorkerProcessIncarnation } from '../structured-worker-identity' import { - ACTOR_X, - ACTOR_Y, + ADDRESS_X, + ADDRESS_Y, createSessionCallerHarness, orchestrationRequest, idOf, @@ -54,7 +54,7 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( expect(h.db.getRunRaw(runId)).toMatchObject({ coordinator_handle: null, coordinator_pane_key: null, - coordinator_actor: ACTOR_X + coordinator_orca_session_id: SESSION_X }) expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: { id: runId } @@ -73,14 +73,14 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( assignee_handle: WORKER_HANDLE, creator_handle: null, creator_pane_key: null, - creator_actor: ACTOR_X, + creator_orca_session_id: SESSION_X, depth: 1 }) // The worker writes to its coordinator's public address. const { message: inbound } = await as(undefined, 'orchestration.send', { from: WORKER_HANDLE, - to: ACTOR_X, + to: ADDRESS_X, subject: 'progress' }) expect(inbound).toMatchObject({ to_handle: `run:${runId}`, run_id: runId }) @@ -92,13 +92,13 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( to: WORKER_HANDLE, subject: 'more' }) - expect(outbound).toMatchObject({ from_handle: ACTOR_X, run_id: runId }) + expect(outbound).toMatchObject({ from_handle: ADDRESS_X, run_id: runId }) const replied = await as(SESSION_X, 'orchestration.reply', { id: idOf(inbound), body: 'ack' }) - expect(replied).toMatchObject({ message: { from_handle: ACTOR_X, to_handle: WORKER_HANDLE } }) + expect(replied).toMatchObject({ message: { from_handle: ADDRESS_X, to_handle: WORKER_HANDLE } }) const { gate } = await as(SESSION_X, 'orchestration.gateCreate', { task: taskId, @@ -123,7 +123,11 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( to: WORKER_HANDLE, subject: 'no dispatch here' }) - expect(message).toMatchObject({ from_handle: ACTOR_X, to_handle: WORKER_HANDLE, run_id: runId }) + expect(message).toMatchObject({ + from_handle: ADDRESS_X, + to_handle: WORKER_HANDLE, + run_id: runId + }) }) it("addresses a group to the session's own Run", async () => { @@ -191,14 +195,14 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( expect(await as(SESSION_X, 'orchestration.runCurrent', {})).toMatchObject({ run: { id: xSecond } }) - expect(h.db.getRunRaw(xFirst)?.coordinator_actor).toBeNull() + expect(h.db.getRunRaw(xFirst)?.coordinator_orca_session_id).toBeNull() }) it('takes over a bound Run like a terminal does, and fences the previous coordinator', async () => { const runId = await runCreate(SESSION_X) await as(undefined, 'orchestration.send', { from: WORKER_HANDLE, - to: ACTOR_X, + to: ADDRESS_X, subject: 'before takeover', run: runId }) @@ -249,7 +253,7 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( }) }) - it('keeps the same actor across a native to terminal-view to native handoff', async () => { + it('keeps the same Orca session id across a native to terminal-view to native handoff', async () => { const runId = await runCreate(SESSION_X) // The terminal view is a PTY: its CLI also carries that terminal's own evidence. const tuiEvidence = { @@ -258,7 +262,11 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( launchToken: 'tui-token' } h.records.set(SESSION_X, sessionRecord(SESSION_X, { lease: { runtimeKind: 'tui' } })) - await as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to: ACTOR_X, subject: 'tui' }) + await as(undefined, 'orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_X, + subject: 'tui' + }) const inTui = resultOf( await h.dispatch( @@ -280,9 +288,9 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( }) }) - it('stops counting a coordinator actor once an older binary rebinds the Run to a terminal', async () => { + it('stops counting a coordinator Orca session id once an older binary rebinds the Run to a terminal', async () => { const runId = await runCreate(SESSION_X) - // An older binary's bindRun rewrites handle and pane and bumps the generation, never the actor. + // An older binary's bindRun rewrites handle and pane and bumps the generation, never the Orca session id. h.db.db .prepare( `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, @@ -349,13 +357,13 @@ describe('a session with no Run, and receipts that carry no caller param', () => }) it('reads its direct mailbox on a consuming check, as a terminal with a live pane does', async () => { - h.db.insertMessage({ from: WORKER_HANDLE, to: ACTOR_X, subject: 'direct', body: '' }) + h.db.insertMessage({ from: WORKER_HANDLE, to: ADDRESS_X, subject: 'direct', body: '' }) const checked = resultOf( await h.dispatch(orchestrationRequest('orchestration.check', {}, { sessionId: SESSION_X })) ) - expect(checked).toMatchObject({ messages: [{ subject: 'direct', to_handle: ACTOR_X }] }) + expect(checked).toMatchObject({ messages: [{ subject: 'direct', to_handle: ADDRESS_X }] }) }) it('binds a receipt to the session even when the method names no caller', async () => { @@ -433,7 +441,7 @@ describe('a structured worker that names itself by session id', () => { it("reads its own Dispatch mailbox: the session id wins and maps to the worker's handle", async () => { const { dispatchId } = dispatchToWorker() - expect(h.db.getDispatchContextById(dispatchId)?.assignee_actor).toBe(ACTOR_Y) + expect(h.db.getDispatchContextById(dispatchId)?.assignee_orca_session_id).toBe(SESSION_Y) const bySession = resultOf( await h.dispatch( @@ -458,7 +466,7 @@ describe('a structured worker that names itself by session id', () => { it.each([ ['its handle', handle], - ['its session address', ACTOR_Y], + ['its session address', ADDRESS_Y], ['its bare session id', workerSession] ])('accepts itself declared as %s and binds by its handle', async (_label, declared) => { const { run } = resultOf( @@ -472,11 +480,53 @@ describe('a structured worker that names itself by session id', () => { ) expect(h.db.getRunRaw(idOf(run))).toMatchObject({ coordinator_handle: handle, - coordinator_actor: ACTOR_Y + coordinator_orca_session_id: SESSION_Y }) }) - it('coordinates with its handle, pane and actor, reachable at both addresses', async () => { + it('keeps mail to its session address direct once assigned in a Run it coordinated before', async () => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: workerSession } + ) + ) + ) + const runId = idOf(run) + // A chat takes the Run over; the worker's addresses stay remembered as a former coordinator's. + resultOf( + await h.dispatch( + orchestrationRequest('orchestration.runUse', { id: runId }, { sessionId: SESSION_X }) + ) + ) + expect(h.db.getRunMailboxOwnerIdsForHandle(ADDRESS_Y)).toEqual([runId]) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(workerSession), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + // Direct mail to the session address, as the mail layer writes it after recipient resolution. + const mail = h.db.insertMessage({ + from: ADDRESS_X, + to: ADDRESS_Y, + subject: 'to the assigned worker', + body: '', + runId + }) + + // The assignee owns mail to its session address, so the Run mailbox must not take it. + expect(mail.to_handle).toBe(ADDRESS_Y) + h.db.routeAllUnreadDirectMessagesToRunMailbox(runId, ADDRESS_Y) + expect(h.db.getMessageById(mail.id)?.to_handle).toBe(ADDRESS_Y) + }) + + it('coordinates with its handle, pane and Orca session id, reachable at both addresses', async () => { const { run } = resultOf( await h.dispatch( orchestrationRequest( @@ -493,9 +543,9 @@ describe('a structured worker that names itself by session id', () => { expect(h.db.getRunRaw(runId)).toMatchObject({ coordinator_handle: handle, coordinator_pane_key: paneKey, - coordinator_actor: ACTOR_Y + coordinator_orca_session_id: SESSION_Y }) expect(h.db.getRunMailboxOwnerIdsForHandle(handle)).toEqual([runId]) - expect(h.db.getRunMailboxOwnerIdsForHandle(ACTOR_Y)).toEqual([runId]) + expect(h.db.getRunMailboxOwnerIdsForHandle(ADDRESS_Y)).toEqual([runId]) }) }) diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index ef21aa8a2b8..4b4e548ac67 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -154,7 +154,7 @@ export class RpcStreamingDispatcher { effectiveParams, invoke, legacyCoordinator?.mutationCallerFingerprint ?? authenticatedCallerFingerprint, - orchestrationCaller?.actor + orchestrationCaller?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) reply(JSON.stringify(successResponse(request.id, envelopeMeta, result))) diff --git a/src/main/runtime/structured-worker-authority.ts b/src/main/runtime/structured-worker-authority.ts index 15dd6470c09..fbefd675b75 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -8,7 +8,7 @@ */ import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { formatOrchestrationActor } from '../../shared/orchestration-actor' +import type { OrcaSessionId } from '../../shared/orca-session-address' import type { RuntimeTerminalState } from '../../shared/runtime-types' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import type { OrchestrationDb } from './orchestration/db' @@ -68,17 +68,17 @@ export function resolveStructuredWorkerIdentityForSession( * Whether this session was assigned a Dispatch as a structured worker. Such a session acts with its * worker handle, so one whose handle is gone must not act handle-less, as a chat would. */ -export function isRecordedStructuredWorkerSession(sessionId: string, db: OrchestrationDb): boolean { +export function isRecordedStructuredWorkerSession( + sessionId: OrcaSessionId, + db: OrchestrationDb +): boolean { return Boolean( db.db .prepare( `SELECT 1 FROM dispatch_contexts - WHERE assignee_actor = ? AND process_incarnation = ? LIMIT 1` - ) - .get( - formatOrchestrationActor({ kind: 'session', id: sessionId }), - structuredWorkerProcessIncarnation(sessionId) + WHERE assignee_orca_session_id = ? AND process_incarnation = ? LIMIT 1` ) + .get(sessionId, structuredWorkerProcessIncarnation(sessionId)) ) } diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index 921f84a509a..624f6abba32 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -25,10 +25,7 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' -import { - formatOrchestrationActor, - sessionOrchestrationActor -} from '../../shared/orchestration-actor' +import { isOrcaSessionId, type OrcaSessionId } from '../../shared/orca-session-address' import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope @@ -127,13 +124,12 @@ export function sessionIdFromStructuredWorkerIncarnation( return sessionId.length > 0 ? sessionId : null } -/** The orchestration actor a `structured:` incarnation names; null for any other. */ -export function structuredWorkerActorForIncarnation( +/** The Orca session id a `structured:` incarnation names; null for any other. */ +export function structuredWorkerOrcaSessionIdForIncarnation( processIncarnation: string | null | undefined -): string | null { +): OrcaSessionId | null { const sessionId = sessionIdFromStructuredWorkerIncarnation(processIncarnation) - const actor = sessionId ? sessionOrchestrationActor(sessionId) : null - return actor ? formatOrchestrationActor(actor) : null + return sessionId !== null && isOrcaSessionId(sessionId) ? sessionId : null } /** Structured sessions can only exist local and outside WSL; anything else is not our authority. */ diff --git a/src/shared/orca-session-address-test-fixture.ts b/src/shared/orca-session-address-test-fixture.ts new file mode 100644 index 00000000000..8c41b49b8b2 --- /dev/null +++ b/src/shared/orca-session-address-test-fixture.ts @@ -0,0 +1,9 @@ +import { isOrcaSessionId, type OrcaSessionId } from './orca-session-address' + +/** A literal Orca session id for a test, checked by the same predicate production uses. */ +export function testOrcaSessionId(id: string): OrcaSessionId { + if (!isOrcaSessionId(id)) { + throw new Error(`Not an Orca session id: ${id}`) + } + return id +} diff --git a/src/shared/orca-session-address.test.ts b/src/shared/orca-session-address.test.ts index 96b8a2268e5..0d9875f936d 100644 --- a/src/shared/orca-session-address.test.ts +++ b/src/shared/orca-session-address.test.ts @@ -5,8 +5,9 @@ import { isOrcaSessionId, parseOrcaSessionAddress } from './orca-session-address' +import { testOrcaSessionId } from './orca-session-address-test-fixture' -const SESSION_ID = '0b7e4c2a-5f1d-4e8a-9c3b-2d6f8a1e4b70' +const SESSION_ID = testOrcaSessionId('0b7e4c2a-5f1d-4e8a-9c3b-2d6f8a1e4b70') const ADDRESS = `session:${SESSION_ID}` describe('Orca session address', () => { @@ -14,7 +15,8 @@ describe('Orca session address', () => { expect(ORCA_SESSION_ADDRESS_PREFIX).toBe('session:') expect(formatOrcaSessionAddress(SESSION_ID)).toBe(ADDRESS) expect(parseOrcaSessionAddress(ADDRESS)).toBe(SESSION_ID) - expect(formatOrcaSessionAddress(parseOrcaSessionAddress(ADDRESS) ?? '')).toBe(ADDRESS) + const parsed = parseOrcaSessionAddress(ADDRESS) + expect(parsed && formatOrcaSessionAddress(parsed)).toBe(ADDRESS) }) it('reads only the addressed spelling when parsing an address', () => { diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index f2ccd4fe00c..339ccb01f43 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -13,24 +13,41 @@ import { isAgentSessionId } from './agent-session-record' */ export const ORCA_SESSION_ADDRESS_PREFIX = 'session:' +declare const orcaSessionIdBrand: unique symbol +declare const orcaSessionAddressBrand: unique symbol + +/** A bare Orca session id; only `isOrcaSessionId` and `parseOrcaSessionAddress` produce one. */ +export type OrcaSessionId = string & { readonly [orcaSessionIdBrand]: true } +/** A `session:` mail address; only `formatOrcaSessionAddress` produces one. */ +export type OrcaSessionAddress = string & { readonly [orcaSessionAddressBrand]: true } + // Terminal handles (`term_` from the PTY runtime, `structworker_` from structured-worker-identity) // share the session-id charset. A handle is never a session, so one handed over by mistake must not // become a durable Orca session id. const TERMINAL_HANDLE_PREFIXES = ['term_', 'structworker_'] as const -export function isOrcaSessionId(id: string): boolean { +export function isOrcaSessionId(id: string): id is OrcaSessionId { return isAgentSessionId(id) && !TERMINAL_HANDLE_PREFIXES.some((prefix) => id.startsWith(prefix)) } -export function formatOrcaSessionAddress(orcaSessionId: string): string { - return `${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}` +export function formatOrcaSessionAddress(orcaSessionId: OrcaSessionId): OrcaSessionAddress { + const address = `${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}` + // Always true for a checked id; the check brands the address without a type assertion. + if (!isOrcaSessionAddress(address)) { + throw new Error(`Not an Orca session id: ${orcaSessionId}`) + } + return address } /** The bare Orca session id of a `session:` address; anything else reads as null. */ -export function parseOrcaSessionAddress(address: string | null | undefined): string | null { +export function parseOrcaSessionAddress(address: string | null | undefined): OrcaSessionId | null { if (!address?.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { return null } const id = address.slice(ORCA_SESSION_ADDRESS_PREFIX.length) return isOrcaSessionId(id) ? id : null } + +function isOrcaSessionAddress(address: string): address is OrcaSessionAddress { + return parseOrcaSessionAddress(address) !== null +} From de0689670b4ec94bdc464c7a63d34635702ae683 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:03:31 -0700 Subject: [PATCH 13/15] fix(orchestration): accept a worker's ask to every address its coordinator is reachable at A worker's preamble names its coordinator as `session:` when the coordinator is a structured session, but ask only accepted `run:` or the coordinator's terminal handle. A chat coordinator has no handle, and a coordinating structured worker has two addresses, so ask --to the session address was refused as dispatch_run_mismatch. The check now takes the Run's current coordinator addresses from addressSpellingsOf(runCoordinatorKey(run)). --- .../orchestration/messaging/ask-methods.ts | 10 +++- .../orchestration-session-coordinator.test.ts | 51 +++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts index ef191250a8b..34879b23307 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/ask-methods.ts @@ -5,6 +5,10 @@ import { isGroupAddress } from '../../../../orchestration/groups' import { AskParams } from '../schemas' import { rejectFederatedExplicitTarget } from '../routing' import { askRemoteRunHome } from './ask-remote' +import { + addressSpellingsOf, + runCoordinatorKey +} from '../../../../orchestration/orchestration-caller-identity' export const ORCHESTRATION_ASK_METHODS = [ defineMethod({ @@ -87,7 +91,11 @@ export const ORCHESTRATION_ASK_METHODS = [ `Dispatch ${activeDispatch.id} belongs to Run ${run.id}, not ${params.run}.` ) } - if (params.to && params.to !== `run:${run.id}` && params.to !== run.coordinator_handle) { + if ( + params.to && + params.to !== `run:${run.id}` && + !addressSpellingsOf(runCoordinatorKey(run)).includes(params.to) + ) { throw new OrchestrationError( 'dispatch_run_mismatch', `ask from Dispatch ${activeDispatch.id} must target its owning Run ${run.id}.` diff --git a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts index 1a1ecb68d67..55e769d84ce 100644 --- a/src/main/runtime/rpc/orchestration-session-coordinator.test.ts +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -165,6 +165,21 @@ describe('a structured chat coordinates through the same verbs as a terminal', ( expect(response).toMatchObject({ ok: false, error: { code: 'dispatch_inactive' } }) }) + it("lets its worker ask it at its session address, the one the worker's preamble names", async () => { + const runId = await runCreate(SESSION_X) + const taskId = idOf((await as(SESSION_X, 'orchestration.taskCreate', { spec: 'q' })).task) + await as(SESSION_X, 'orchestration.dispatch', { task: taskId, to: WORKER_HANDLE }) + + const asked = await as(undefined, 'orchestration.ask', { + from: WORKER_HANDLE, + to: ADDRESS_X, + question: 'which way?', + timeoutMs: 0 + }) + expect(asked).toMatchObject({ timedOut: true }) + expect(h.db.getQuestion(String(asked.messageId))).toMatchObject({ run_id: runId }) + }) + it("places a worker-start in the session's own workspace", async () => { const runId = await runCreate(SESSION_X) vi.spyOn(h.runtime, 'validateOrchestrationAgentLauncher').mockImplementation(() => {}) @@ -548,4 +563,40 @@ describe('a structured worker that names itself by session id', () => { expect(h.db.getRunMailboxOwnerIdsForHandle(handle)).toEqual([runId]) expect(h.db.getRunMailboxOwnerIdsForHandle(ADDRESS_Y)).toEqual([runId]) }) + + it.each([ + ['its handle', handle], + ['its session address', ADDRESS_Y] + ])('is asked by its own worker at %s', async (_label, to) => { + const { run } = resultOf( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'o' }, + { sessionId: workerSession } + ) + ) + ) + const runId = idOf(run) + h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'sub' }).id, + assigneeHandle: WORKER_HANDLE, + assigneePaneKey: WORKER_PANE, + creator: { kind: 'terminal', handle, paneKey, orcaSessionId: workerSession }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const asked = resultOf( + await h.dispatch( + orchestrationRequest('orchestration.ask', { + from: WORKER_HANDLE, + to, + question: 'which way?', + timeoutMs: 0 + }) + ) + ) + expect(asked).toMatchObject({ timedOut: true }) + expect(h.db.getQuestion(String(asked.messageId))).toMatchObject({ run_id: runId }) + }) }) From a44c26e8eedf8ea41e09379bfe35c4050e9c1d0d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:07:47 -0700 Subject: [PATCH 14/15] refactor(orchestration): require every caller-identity entry point to be handed the resolved session The resolved session parameter was optional on resolveRunScope, resolveOrchestrationCaller, orchestrationCallerIdentity, resolveDispatchCreator and resolveDispatchCallerWorktreeId, so a method that forgot to pass it would compile and silently treat a chat's session address as a terminal handle. It is now required and typed `OrchestrationSessionCaller | undefined`, so leaving it out is a type error. Every call site already passed it; no behavior changes. --- .../runtime/rpc/methods/orchestration-caller-workspace.ts | 2 +- .../rpc/methods/orchestration/runs/dispatch-creator.ts | 2 +- .../runtime/rpc/methods/orchestration/runs/run-scope.ts | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts index 1c94a195651..a3bbe7f01a3 100644 --- a/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts +++ b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts @@ -20,7 +20,7 @@ import { isStructuredWorkerHandle } from '../../structured-worker-identity' export async function resolveDispatchCallerWorktreeId( runtime: Pick, callerHandle: string, - callerSession?: OrchestrationSessionCaller + callerSession: OrchestrationSessionCaller | undefined ): Promise { // A session caller's workspace is on its record, whichever owner (chat or terminal) holds it. if (callerSession) { diff --git a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts index 269c1f3ff4c..0ee7f7523e7 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/dispatch-creator.ts @@ -13,7 +13,7 @@ import { orchestrationCallerIdentity } from './run-scope' export function resolveDispatchCreator( runtime: OrcaRuntimeService, callerHandle: string | undefined, - callerSession?: OrchestrationSessionCaller + callerSession: OrchestrationSessionCaller | undefined ): DispatchCreator { if (!callerHandle) { // No declared caller means no resolvable parent. Depth 0 is the same answer diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts index a75f566f028..52f9d247f9a 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-scope.ts @@ -19,7 +19,7 @@ export type RunScopeParams = { callerTerminalHandle?: string callerPaneKey?: string /** Resolved at the dispatch entry; when set it is the caller, whatever the declared handle. */ - callerSession?: OrchestrationSessionCaller + callerSession: OrchestrationSessionCaller | undefined requireCurrentConsumer: boolean legacyCoordinatorRunId?: string // Why: the caller's declared handle is a user param; this is the attested one to check it against. @@ -55,7 +55,7 @@ export function orchestrationCallerIdentity( caller: { handle: string paneKey: string | null | undefined - session?: OrchestrationSessionCaller + session: OrchestrationSessionCaller | undefined } ): OrchestrationCallerIdentity { if (caller.session) { @@ -74,7 +74,7 @@ export type OrchestrationCallerParams = { callerTerminalHandle: string callerEvidence?: OrchestrationCompatibilityEvidence callerAuthority?: OrchestrationCompatibilityCallerAuthority - callerSession?: OrchestrationSessionCaller + callerSession: OrchestrationSessionCaller | undefined /** Preserve legacy callers that treated a missing pane as an ordinary fence. */ requireStablePane?: boolean /** From 47eef51619907f4de17ccceae068b86df8868af5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:30:17 -0700 Subject: [PATCH 15/15] fix(orchestration): deliver mail sent to a session address to the mailbox that session reads A send to session: was resolved like a terminal handle: no live pane, so a chat's current Run was missed (two Runs read as ambiguous), a Run-less chat was refused though it reads its direct mailbox, and a structured worker's session address never reached its Dispatch. Resolve a worker's session address as its handle, a chat's by its bound Run, and fall back to the chat's durable direct mailbox while it runs on this host. --- .../messaging/recipient-routing.ts | 34 ++++- .../orchestration-session-recipient.test.ts | 121 ++++++++++++++++++ 2 files changed, 152 insertions(+), 3 deletions(-) create mode 100644 src/main/runtime/rpc/orchestration-session-recipient.test.ts diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts index f2b5e939a65..6fc152e7bc5 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts @@ -2,6 +2,12 @@ import type { LegacyAdoptedMailboxOwner, OrchestrationDb } from '../../../../orc import { OrchestrationError } from '../../../../orchestration/orchestration-error' import type { DispatchContextRow, DispatchStatus } from '../../../../orchestration/types' import type { OrcaRuntimeService } from '../../../../orca-runtime' +import { parseOrcaSessionAddress } from '../../../../../../shared/orca-session-address' +import { + readStructuredAgentSessionRecord, + resolveStructuredWorkerIdentityForSession +} from '../../../../structured-worker-authority' +import { structuredWorkerHostScope } from '../../../../structured-worker-identity' const ACTIVE_DISPATCH_STATUSES: readonly DispatchStatus[] = ['pending', 'dispatched'] @@ -56,8 +62,20 @@ export function resolveBareOrchestrationRecipient(params: { legacyAdoptedMailboxOwner?: LegacyAdoptedMailboxOwner | null }): BareRecipientResolution { const { runtime, db, handle } = params - const paneKey = runtime.getLiveTerminalPaneKey(handle) ?? undefined - const boundRun = paneKey ? db.getCurrentRunForPane(paneKey) : undefined + const orcaSessionId = parseOrcaSessionAddress(handle) + if (orcaSessionId) { + // A structured worker's session address names the worker, which reads its mail as its handle. + const worker = resolveStructuredWorkerIdentityForSession(orcaSessionId, db) + if (worker) { + return resolveBareOrchestrationRecipient({ ...params, handle: worker.handle }) + } + } + const paneKey = orcaSessionId ? undefined : (runtime.getLiveTerminalPaneKey(handle) ?? undefined) + const boundRun = orcaSessionId + ? db.getCurrentRunForCoordinator({ terminalHandle: null, paneKey: null, orcaSessionId }) + : paneKey + ? db.getCurrentRunForPane(paneKey) + : undefined if (boundRun) { const mismatch = runMismatch(handle, boundRun.id, params.explicitRunId) return mismatch ?? { ok: true, to: `run:${boundRun.id}`, runId: boundRun.id } @@ -102,7 +120,17 @@ export function resolveBareOrchestrationRecipient(params: { } } - const message = `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` + if (orcaSessionId) { + const record = readStructuredAgentSessionRecord(orcaSessionId) + // Unlike a terminal handle, a session address outlives its process, so its direct mail is durable. + if (record && structuredWorkerHostScope(record.location)) { + return { ok: true, to: handle, runId: params.senderRunId } + } + } + + const message = orcaSessionId + ? `Agent session ${orcaSessionId} does not run on this host and has no durable Run/Dispatch mailbox.` + : `Terminal ${handle} has no live pane or durable Run/Dispatch mailbox.` return { ok: false, code: 'terminal_not_found', diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts new file mode 100644 index 00000000000..bbf199e7f50 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerIdentities, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + ADDRESS_Y, + createSessionCallerHarness, + orchestrationRequest, + idOf, + resultOf, + SESSION_X, + SESSION_Y, + sessionRecord, + WORKER_HANDLE, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +type Row = Record + +describe('mail sent to a session address reaches the mailbox that session reads', () => { + let h: SessionCallerHarness + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + async function as(sessionId: string | undefined, method: string, params: Row): Promise { + return resultOf(await h.dispatch(orchestrationRequest(method, params, { sessionId }))) + } + + function sendFromTerminal(to: string): Promise { + return as(undefined, 'orchestration.send', { from: WORKER_HANDLE, to, subject: 'hello' }) + } + + it("files it under the chat's current Run, as a terminal coordinator's pane does", async () => { + await as(SESSION_X, 'orchestration.runCreate', { objective: 'first' }) + const current = idOf( + (await as(SESSION_X, 'orchestration.runCreate', { objective: 'next' })).run + ) + + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: `run:${current}`, run_id: current }) + expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ + runId: current, + messages: [{ subject: 'hello' }] + }) + }) + + it('delivers it to a chat with no Run, which reads its direct mailbox', async () => { + const { message } = await sendFromTerminal(ADDRESS_X) + + expect(message).toMatchObject({ to_handle: ADDRESS_X }) + expect(await as(SESSION_X, 'orchestration.check', {})).toMatchObject({ + messages: [{ subject: 'hello' }] + }) + }) + + it('refuses an Orca session this host does not run', async () => { + h.records.set( + SESSION_X, + sessionRecord(SESSION_X, { location: { executionHostId: 'ssh:devbox' } }) + ) + h.records.delete(SESSION_Y) + + for (const to of [ADDRESS_X, ADDRESS_Y]) { + const response = await h.dispatch( + orchestrationRequest('orchestration.send', { from: WORKER_HANDLE, to, subject: 's' }) + ) + expect(response).toMatchObject({ ok: false, error: { code: 'terminal_not_found' } }) + } + }) + + it("routes a structured worker's session address to the Dispatch it is working", async () => { + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(SESSION_Y) + structuredWorkerIdentities.register({ + handle, + sessionId: SESSION_Y, + agent: 'claude', + paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + worktreeId: 'wt_1', + hostScope: { kind: 'local', hostId: 'local' } + }) + const runId = idOf((await as(SESSION_X, 'orchestration.runCreate', { objective: 'o' })).run) + const dispatch = h.db.createDispatchContext({ + taskId: h.db.createTask({ runId, spec: 'work' }).id, + assigneeHandle: handle, + assigneePaneKey: paneKey, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_Y), + creator: { kind: 'session', orcaSessionId: SESSION_X }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + + const { message } = await as(SESSION_X, 'orchestration.send', { + to: ADDRESS_Y, + subject: 'to the worker' + }) + + expect(message).toMatchObject({ to_handle: `dispatch:${dispatch.id}`, run_id: runId }) + expect(await as(SESSION_Y, 'orchestration.check', { peek: true })).toMatchObject({ + dispatchId: dispatch.id, + messages: [{ subject: 'to the worker' }] + }) + }) +})