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..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,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 { structuredWorkerOrcaSessionIdForIncarnation } from '../../../structured-worker-identity' export function createDispatchContext( this: OrchestrationDb, @@ -65,6 +66,7 @@ export function createDispatchContext( launchTokenHash: launchTokenHash ?? null, assigneeHandle, assigneePaneKey: assigneePaneKey ?? null, + 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 cc4ba026db9..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,17 +27,33 @@ export type DispatchCreator = paneKey?: string /** Remote attachment matching requires the exact incarnation; local rows do not. */ processIncarnation?: string + /** 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 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 + creatorOrcaSessionId: OrcaSessionId | null } { if (creator.kind === 'system') { - return { creatorHandle: null, creatorPaneKey: null } + return { creatorHandle: null, creatorPaneKey: null, creatorOrcaSessionId: null } + } + if (creator.kind === 'session') { + return { + creatorHandle: null, + creatorPaneKey: null, + creatorOrcaSessionId: creator.orcaSessionId + } + } + return { + creatorHandle: creator.handle, + creatorPaneKey: creator.paneKey ?? null, + creatorOrcaSessionId: creator.orcaSessionId ?? null } - return { creatorHandle: creator.handle, creatorPaneKey: creator.paneKey ?? null } } /** @@ -84,14 +101,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 +126,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 === 'terminal') { + return this.findActiveDispatchForAssignee(creator.handle, creator.paneKey) + } + const row = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE assignee_orca_session_id = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .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 +} + /** * 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..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,11 +15,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_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') +SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') FROM tasks WHERE id = ? AND status = 'ready' AND NOT EXISTS ( @@ -45,8 +46,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_orca_session_id, 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 +72,12 @@ export function claimDispatchContextRow( launchTokenHash: string | null assigneeHandle: string assigneePaneKey: string | null + assigneeOrcaSessionId?: OrcaSessionId | null processIncarnation: string | null creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorOrcaSessionId?: OrcaSessionId | null priorFailures: number depth: number taskId: string @@ -89,10 +93,12 @@ export function claimDispatchContextRow( params.launchTokenHash, params.assigneeHandle, params.assigneePaneKey, + params.assigneeOrcaSessionId ?? null, params.processIncarnation, params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorOrcaSessionId ?? null, params.priorFailures, params.depth, params.taskId, @@ -118,6 +124,7 @@ export function insertStartingDispatchContextRow( creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorOrcaSessionId?: OrcaSessionId | null } ): void { assertStampedDepth(params.depth) @@ -131,6 +138,7 @@ export function insertStartingDispatchContextRow( params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorOrcaSessionId ?? 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..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,4 +1,5 @@ import { parsePaneKey } from '../../../../../shared/stable-pane-id' +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' @@ -20,9 +21,27 @@ 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. + const directOrcaSessionId = parseOrcaSessionAddress(directHandle) + if (directOrcaSessionId) { + const bySession = this.db + .prepare( + `SELECT * FROM dispatch_contexts + WHERE run_id = ? AND assignee_orca_session_id = ? AND status IN ('pending', 'dispatched') + ORDER BY rowid DESC LIMIT 1` + ) + .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 bySession as DispatchContextRow + } + } + if (!paneKey || !parsePaneKey(paneKey)) { + return undefined + } return this.db .prepare( `SELECT * FROM dispatch_contexts @@ -76,6 +95,33 @@ export function routeForeignDirectMessagesToOwnedMailboxes( [directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1], [directHandle, directHandle, ...exclusionParams, ORCHESTRATION_DELIVERY_BATCH_LIMIT + 1] ] + const directOrcaSessionId = parseOrcaSessionAddress(directHandle) + if (directOrcaSessionId) { + branches.push( + `SELECT candidate.id, candidate.run_id, candidate.type, candidate.sequence + FROM ( + 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 = 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 = 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([ + directOrcaSessionId, + 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/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 c5e6f34abfa..b511972ad96 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -3,13 +3,21 @@ 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, + runCoordinatorKey +} from '../../orchestration-caller-identity' export function bindRun( this: OrchestrationDb, params: { runId: string - coordinatorHandle: string - coordinatorPaneKey: string + coordinatorHandle: string | null + coordinatorPaneKey: 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 @@ -20,6 +28,11 @@ export function bindRun( } } ): RunRow | undefined { + const coordinator = { + terminalHandle: params.coordinatorHandle, + paneKey: params.coordinatorPaneKey, + orcaSessionId: params.coordinatorOrcaSessionId ?? null + } this.db.exec('BEGIN IMMEDIATE') try { const run = this.getRunRaw(params.runId) @@ -27,9 +40,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 +60,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 +121,14 @@ 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.rememberRunCoordinatorHandle(params.runId, handle) - this.routeAllUnreadDirectMessagesToRunMailbox(params.runId, handle) + this.unbindOtherRunsForCoordinator(coordinator, params.runId) + // 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) } if ( (params.takeoverLegacy && !takeoverAlreadyApplied) || @@ -128,26 +140,40 @@ 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.orcaSessionId, + params.runId + ) this.fenceUnacknowledgedMailboxDeliveries(`run:${params.runId}`) if (params.takeoverLegacy || replacesLegacyCoordinator) { this.promoteLegacyCoordinatorMailForTakeover(params.runId, retainedCoordinatorHandle) } + } 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_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation + WHERE id = ?` + ) + .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 07a6044d5aa..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,5 +1,20 @@ 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 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 ${orcaSessionAddressSql('dispatch_contexts.assignee_orca_session_id')} = ${addressSql}) + AND dispatch_contexts.status IN ('pending', 'dispatched') + )` +} export function rememberRunCoordinatorHandle( this: OrchestrationDb, @@ -43,11 +58,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 +80,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-coordinator-orca-session-binding.test.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts new file mode 100644 index 00000000000..1aeb1699a23 --- /dev/null +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session-binding.test.ts @@ -0,0 +1,526 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../../../structured-worker-identity' +import { OrchestrationDb } from '../../db' +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(orcaSessionId: OrcaSessionId) { + return { terminalHandle: null, paneKey: null, orcaSessionId } +} + +describe('Run binding by Orca session id', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + function createChatRun(orcaSessionId: OrcaSessionId, objective = 'chat run') { + return db.createRun({ + objective, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: orcaSessionId + }) + } + + function directMail(runId: string, to: string, subject = 'direct') { + return db.insertMessage({ from: 'term_sender', to, subject, body: '', runId }) + } + + function structuredWorker(sessionId = WORKER_SESSION) { + return { + terminalHandle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(sessionId), + orcaSessionId: sessionId, + address: formatOrcaSessionAddress(sessionId) + } + } + + // A binary without the Orca session id 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 Orca session id and remembers its session address', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_orca_session_id: CHAT_X_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}`) + }) + + 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_ID, 'y') + const xFirst = createChatRun(CHAT_X_ID, '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_ID, 'x second') + + 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_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 Orca session id once a binary without the column rebinds the Run to a terminal', () => { + db = new OrchestrationDb(':memory:') + const run = createChatRun(CHAT_X_ID) + olderBinaryRebind(run.id, 'term_taker', PTY_PANE) + + expect(db.getCurrentRunForCoordinator(chat(CHAT_X_ID))).toBeUndefined() + expect( + db.getCurrentRunForCoordinator({ + terminalHandle: 'term_taker', + paneKey: PTY_PANE, + orcaSessionId: null + })?.id + ).toBe(run.id) + 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_ID) + olderBinaryRebind(run.id, 'term_taker', PTY_PANE) + olderBinaryUnbind(run.id) + + // 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_orca_session_id: CHAT_X_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 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, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + expect(db.getCurrentRunForCoordinator(worker)?.id).toBe(run.id) + olderBinaryUnbind(run.id) + expect(db.getCurrentRunForCoordinator(worker)).toBeUndefined() + expect(db.getCurrentRunForCoordinator(chat(worker.orcaSessionId))).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, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + + expect(db.getRunMailboxOwnerIdsForHandle(worker.terminalHandle)).toEqual([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_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 + + db.bindRun({ + runId: run.id, + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: CHAT_Y_ID + }) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_orca_session_id: CHAT_Y_ID, + consumer_generation: before + 1 + }) + 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 Orca session id in place', () => { + db = new OrchestrationDb(':memory:') + const worker = structuredWorker() + const run = db.createRun({ + objective: 'worker coordinates', + coordinatorHandle: worker.terminalHandle, + coordinatorPaneKey: worker.paneKey + }) + // 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, + coordinatorOrcaSessionId: worker.orcaSessionId + }) + + expect(db.getRunRaw(run.id)).toMatchObject({ + coordinator_orca_session_id: WORKER_SESSION, + consumer_generation: before + }) + // 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", () => { + 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, + coordinatorOrcaSessionId: first.orcaSessionId + }) + 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, + coordinatorOrcaSessionId: second.orcaSessionId + }) + + 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, + coordinatorOrcaSessionId: worker.orcaSessionId + } + const last = db.createRun({ objective: 'last', ...bind }) + const stray = [worker.terminalHandle, worker.address].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}`) + } + }) +}) + +describe('mail owned by an active Dispatch assignee addressed by its session address', () => { + 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, + coordinatorOrcaSessionId: WORKER_SESSION + }) + 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_ADDRESS, + subject: 's', + body: '', + runId: run.id + }).to_handle + ).toBe(WORKER_ADDRESS) + }) + + 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_ADDRESS, + subject: 's', + body: '', + runId: run.id + }) + + db.routeAllUnreadDirectMessagesToRunMailbox(run.id, WORKER_ADDRESS) + + expect(db.getMessageById(mail.id)?.to_handle).toBe(WORKER_ADDRESS) + }) + + 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_ADDRESS, + subject: 's', + body: '', + runId: run.id + }) + + const routed = db.routeForeignDirectMessagesToOwnedMailboxes(WORKER_ADDRESS) + + expect(routed.routedCount).toBe(1) + expect(db.getMessageById(stray.id)?.to_handle).toBe(`dispatch:${dispatch.id}`) + }) +}) + +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_ADDRESS, + subject: 's', + body: '', + runId: run.id + }) + expect(db.getMessageById(stray.id)?.to_handle).toBe(WORKER_ADDRESS) + + expect(db.routeForeignDirectMessagesToOwnedMailboxes(WORKER_ADDRESS).routedCount).toBe(1) + expect(db.getMessageById(stray.id)?.to_handle).toBe(`dispatch:${dispatch.id}`) + }) +}) + +describe('Dispatch Orca session ids recorded by every writer', () => { + let db: OrchestrationDb + + afterEach(() => { + db?.close() + }) + + 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, + coordinatorOrcaSessionId: CHAT_X_ID + }) + 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: 'session', orcaSessionId: CHAT_X_ID }, + 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_orca_session_id: WORKER_SESSION, + creator_handle: null, + creator_pane_key: null, + 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(child).toMatchObject({ creator_dispatch_id: parent.id, depth: parent.depth + 1 }) + }) + + 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, + coordinatorOrcaSessionId: CHAT_X_ID + }) + const started = db.createStartingWorkerDispatch({ + creator: { kind: 'session', orcaSessionId: CHAT_X_ID }, + maxDepth: UNCAPPED, + taskSpec: 'work', + taskRunId: run.id, + startOptions: {} + }) + expect(started.dispatch).toMatchObject({ + creator_orca_session_id: CHAT_X_ID, + assignee_orca_session_id: 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_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 3f99e018936..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,8 @@ 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 ── @@ -8,23 +10,38 @@ export function createRun( this: OrchestrationDb, params: { objective: string - coordinatorHandle: string - coordinatorPaneKey: string + coordinatorHandle: string | null + coordinatorPaneKey: 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, + orcaSessionId: params.coordinatorOrcaSessionId ?? 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, - consumer_generation, legacy - ) VALUES (?, ?, ?, ?, 1, 0)` + 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.orcaSessionId ) - .run(id, params.objective, params.coordinatorHandle, params.coordinatorPaneKey) - this.rememberRunCoordinatorHandle(id, params.coordinatorHandle) + for (const address of addressSpellingsOf(coordinator)) { + 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..7109e1594aa 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -1,4 +1,10 @@ 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' import { isEquivalentPaneKey, @@ -22,6 +28,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 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_orca_session_id = ? + ) + ORDER BY rowid` export function getRun(this: OrchestrationDb, id: string): RunRow | undefined { const run = this.getRunRaw(id) @@ -122,15 +135,37 @@ 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 Orca session id; a caller without one matches as before. */ +export function runsBoundToCoordinator( + this: OrchestrationDb, + caller: OrchestrationCoordinatorKey +): RunRow[] { + 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.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)) +} + +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 addressSpellingsOf(runCoordinatorKey(run))) { + 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/schema/structured-worker-orca-session-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts index 57bf53e7026..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,6 +96,10 @@ describe('structured worker Orca session id backfill', () => { processIncarnation: structuredWorkerProcessIncarnation(SESSION_B), ownership: 'owned' }) + // 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 166ccd7193e..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,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 { structuredWorkerOrcaSessionIdForIncarnation } 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, + 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 c9b9b5d0afd..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,5 +1,6 @@ import type { WorkerDispatchRow } from '../../types' import type { OrchestrationDb } from '../orchestration-db' +import { structuredWorkerOrcaSessionIdForIncarnation } 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, + structuredWorkerOrcaSessionIdForIncarnation(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..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 @@ -123,6 +123,20 @@ export function getWorkerTerminalResourceByHandle( .get(terminalHandle) as WorkerTerminalResourceRow | undefined } +export function getWorkerTerminalResourceByProcessIncarnation( + this: OrchestrationDb, + processIncarnation: string +): WorkerTerminalResourceRow | undefined { + const row = this.db + .prepare( + `SELECT * FROM worker_terminal_resources + WHERE process_incarnation = ? ORDER BY updated_at DESC LIMIT 1` + ) + .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( this: OrchestrationDb, dispatchId: string @@ -238,6 +252,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 +266,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..7770d3a8331 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-caller-identity.ts @@ -0,0 +1,79 @@ +import type { RunRow } from './types' +import { isEquivalentPaneKey } from './db/pane-key-match' +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 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 session address. */ + address: string + terminalHandle: string | null + paneKey: 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' | 'orcaSessionId' +> + +/** A caller the dispatch entry resolved from the Orca session id in its injected environment. */ +export type OrchestrationSessionCaller = OrchestrationCallerIdentity & + Readonly<{ + 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 Orca session id can never be bound to a Run. */ +export function hasRunBindingKey(caller: OrchestrationCoordinatorKey): boolean { + return caller.paneKey !== null || caller.orcaSessionId !== null +} + +/** + * Every address one party is reachable at. A structured worker has two, its handle and its session + * address, so every consumer that remembers, reroutes or compares a party's mail takes this set. + */ +export function addressSpellingsOf( + party: Pick +): string[] { + 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 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, + orcaSessionId: currentRunCoordinatorOrcaSessionId(run) + } +} + +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.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 69186334828..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', @@ -49,6 +50,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 +137,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 +441,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/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 93fa58fd6b5..56fa2d6bb9d 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: () => [], @@ -262,12 +263,19 @@ 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, db: db as never, from: 'term_sender', groupAddress: '@codex', + 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/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..d8bf3e25b53 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?.orcaSessionId ) 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..51c64fdd383 100644 --- a/src/main/runtime/rpc/dispatcher.ts +++ b/src/main/runtime/rpc/dispatcher.ts @@ -23,6 +23,11 @@ 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 { + claimsOrchestrationSession, + resolveOrchestrationSessionCaller, + type ResolvedOrchestrationRequest +} from './orchestration-session-caller' export type DispatcherOptions = { runtime: OrcaRuntimeService @@ -69,7 +74,15 @@ export class RpcDispatcher { return migrationFence } - const parsedParams = parseRpcRequestParams(request, method, meta) + let resolved: ResolvedOrchestrationRequest = { request } + if (claimsOrchestrationSession(request)) { + try { + 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 } @@ -89,7 +102,7 @@ export class RpcDispatcher { try { const result = await invokeDispatcherUnaryMethod({ runtime: this.runtime, - request, + request: resolved.request, method, params: parsedParams.value, context: { @@ -102,7 +115,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/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/methods/orchestration-caller-workspace.ts b/src/main/runtime/rpc/methods/orchestration-caller-workspace.ts index 556938eef23..a3bbe7f01a3 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 | undefined ): 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/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/methods/orchestration/messaging/check-methods.ts b/src/main/runtime/rpc/methods/orchestration/messaging/check-methods.ts index 20ac25e6512..ee52efed6b9 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 = hasRunBindingKey(caller) ? db.getCurrentRunForCoordinator(caller) : undefined 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/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/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..0ee7f7523e7 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,35 @@ import type { OrcaRuntimeService } from '../../../../orca-runtime' */ export function resolveDispatchCreator( runtime: OrcaRuntimeService, - callerHandle: string | undefined + callerHandle: string | undefined, + callerSession: OrchestrationSessionCaller | undefined ): 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 Orca session id is its whole identity. + return caller.orcaSessionId + ? { kind: 'session', orcaSessionId: caller.orcaSessionId } + : { 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.orcaSessionId ? { orcaSessionId: caller.orcaSessionId } : {}) } } 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-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 6b066723315..52f9d247f9a 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 { isOrcaSessionId } from '../../../../../../shared/orca-session-address' 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 | undefined 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 Orca session id a structured worker's handle was minted for. + */ +export function orchestrationCallerIdentity( + runtime: OrcaRuntimeService, + caller: { + handle: string + paneKey: string | null | undefined + session: OrchestrationSessionCaller | undefined + } +): 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, + orcaSessionId: worker && isOrcaSessionId(worker.sessionId) ? worker.sessionId : null + } +} + export type OrchestrationCallerParams = { callerTerminalHandle: string callerEvidence?: OrchestrationCompatibilityEvidence callerAuthority?: OrchestrationCompatibilityCallerAuthority + callerSession: OrchestrationSessionCaller | undefined /** 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..f074b5eba03 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, + coordinatorOrcaSessionId: caller.orcaSessionId }) 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, + coordinatorOrcaSessionId: caller.orcaSessionId, 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..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 { @@ -53,7 +54,9 @@ export class OrchestrationMutationExecutor { request: RpcRequest, params: unknown, invoke: (mutation?: DurableMutationInvocation) => unknown, - callerFingerprintOverride?: string + callerFingerprintOverride?: 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)) { @@ -61,7 +64,7 @@ export class OrchestrationMutationExecutor { } const callerFingerprint = callerFingerprintOverride ?? this.getLocalAuthenticatedCallerFingerprint() - const stableParams = replayStableCallerParams(this.runtime, params) + 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 25a3fa1c177..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,12 +14,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_ORCA_SESSION_ID_KEY = '__orcaCallerOrcaSessionId' + +export function replayStableCallerParams( + runtime: OrcaRuntimeService, + params: unknown, + callerOrcaSessionId?: OrcaSessionId +): 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 = callerOrcaSessionId + ? { ...source, [CALLER_ORCA_SESSION_ID_KEY]: callerOrcaSessionId } + : { ...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-test-fixture.ts b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts new file mode 100644 index 00000000000..396b0b2bec7 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller-test-fixture.ts @@ -0,0 +1,167 @@ +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 { 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' +import type { RpcRequest, RpcResponse } from './core' +import { RpcDispatcher } from './dispatcher' +import { ORCHESTRATION_METHODS } from './methods/orchestration' + +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' +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..6037e3d2384 --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -0,0 +1,396 @@ +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 { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { + ADDRESS_X, + createSessionCallerHarness, + orchestrationRequest, + PROVIDER_ID_X, + idOf, + resultOf, + SESSION_X, + SESSION_Y, + 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 PARTY_NAMING_FIELDS = ['from', 'terminal', 'callerTerminalHandle'] as const +// 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', + '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 partyNaming = [...registry.values()] + .filter((method) => { + const schema = method.params + return ( + schema instanceof ZodObject && + PARTY_NAMING_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(partyNaming).toHaveLength(21) + expect(partyNaming).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, + 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', () => { + function seedPendingMail(): { runId: string; messageId: string } { + const run = h.db.createRun({ + objective: 'x', + coordinatorHandle: null, + coordinatorPaneKey: null, + coordinatorOrcaSessionId: SESSION_X + }) + const message = h.db.insertMessage({ + from: 'term_worker', + to: ADDRESS_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, /is not running right now/) + }) + + 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('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_orca_session_id !== 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/) + }) + }) + + 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([ADDRESS_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_orca_session_id).toBeNull() + }) +}) 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..b579e52c88f --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -0,0 +1,254 @@ +/** + * Resolves an orchestration caller that names itself by the Orca agent session id in its injected + * 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: + * - 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 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 { 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' +import { + addressSpellingsOf, + type OrchestrationSessionCaller +} from '../orchestration/orchestration-caller-identity' +import { OrchestrationError } from '../orchestration/orchestration-error' +import { + isRecordedStructuredWorkerSession, + 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 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> = { + '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 = { + /** The request with its declared caller bound to the session and its evidence reduced to it. */ + request: RpcRequest + caller?: OrchestrationSessionCaller +} + +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, + route: OrchestrationRequestRoute | undefined +): Promise { + const evidence = request.orchestrationCompatibilityEvidence + const claimed: unknown = evidence?.agentSessionId + 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.` + ) + } + 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 = claimed + const record = await readSessionRecord(runtime, sessionId) + assertSessionCanAct(sessionId, record) + const db = runtime.getOrchestrationDb() + const worker = resolveStructuredWorkerIdentityForSession(sessionId, db) + 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, + `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, + orcaSessionId: sessionId, + address: terminalHandle ?? formatOrcaSessionAddress(sessionId), + terminalHandle, + paneKey: worker?.paneKey ?? null, + workspaceId: record.location.workspaceId + }) + return { + 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 + } +} + +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' + ? // 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.' + 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[] = [...addressSpellingsOf(caller), caller.sessionId] + 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/orchestration-session-coordinator.test.ts b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts new file mode 100644 index 00000000000..55e769d84ce --- /dev/null +++ b/src/main/runtime/rpc/orchestration-session-coordinator.test.ts @@ -0,0 +1,602 @@ +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, + 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_orca_session_id: SESSION_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_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: ADDRESS_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: ADDRESS_X, run_id: runId }) + + const replied = await as(SESSION_X, 'orchestration.reply', { + id: idOf(inbound), + body: 'ack' + }) + expect(replied).toMatchObject({ message: { from_handle: ADDRESS_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("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: ADDRESS_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( + orchestrationRequest( + 'orchestration.ask', + { question: 'q', to: WORKER_HANDLE }, + { + sessionId: SESSION_X + } + ) + ) + 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(() => {}) + 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_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: ADDRESS_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 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 = { + 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: ADDRESS_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('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 Orca session id. + h.db.db + .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 }) + 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 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: 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: ADDRESS_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 + 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_orca_session_id).toBe(SESSION_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.each([ + ['its handle', handle], + ['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( + await h.dispatch( + orchestrationRequest( + 'orchestration.runCreate', + { objective: 'declared', from: declared }, + { sessionId: workerSession } + ) + ) + ) + expect(h.db.getRunRaw(idOf(run))).toMatchObject({ + coordinator_handle: handle, + coordinator_orca_session_id: SESSION_Y + }) + }) + + 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( + 'orchestration.runCreate', + { objective: 'nested' }, + { + sessionId: workerSession + } + ) + ) + ) + const runId = idOf(run) + + expect(h.db.getRunRaw(runId)).toMatchObject({ + coordinator_handle: handle, + coordinator_pane_key: paneKey, + coordinator_orca_session_id: SESSION_Y + }) + 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 }) + }) +}) 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' }] + }) + }) +}) diff --git a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts index 6eddcb748be..4b4e548ac67 100644 --- a/src/main/runtime/rpc/rpc-streaming-dispatcher.ts +++ b/src/main/runtime/rpc/rpc-streaming-dispatcher.ts @@ -15,6 +15,11 @@ 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 { + claimsOrchestrationSession, + resolveOrchestrationSessionCaller, + type ResolvedOrchestrationRequest +} from './orchestration-session-caller' export type RpcStreamingDispatcherDependencies = { runtime: OrcaRuntimeService @@ -29,10 +34,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() @@ -57,18 +63,31 @@ export class RpcStreamingDispatcher { return } + // 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, options) + } catch (error) { + reply(JSON.stringify(mapDispatcherError(request, envelopeMeta, error))) + return + } + } + request = resolved.request + 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 { const clientHostedBrowser = await routeDispatcherClientHostedBrowserRpc( runtime, request.method, - parsedParams.value + params ) if (clientHostedBrowser.handled) { recordRuntimeFeatureInteraction( @@ -83,16 +102,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 +145,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?.orcaSessionId ) recordRuntimeFeatureInteraction(runtime, request.method, result, undefined, request.params) reply(JSON.stringify(successResponse(request.id, envelopeMeta, result))) @@ -156,7 +173,7 @@ export class RpcStreamingDispatcher { try { const result = await method.handler( - parsedParams.value, + params, { runtime, signal: options?.signal, @@ -171,7 +188,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..fbefd675b75 100644 --- a/src/main/runtime/structured-worker-authority.ts +++ b/src/main/runtime/structured-worker-authority.ts @@ -8,12 +8,14 @@ */ import type { AgentSessionRecord } from '../../shared/agent-session-record' +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' import { isStructuredWorkerHandle, structuredWorkerIdentities, + structuredWorkerProcessIncarnation, structuredWorkerRecordIsCurrent, type StructuredWorkerIdentity } from './structured-worker-identity' @@ -47,6 +49,39 @@ 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 +} + +/** + * 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: OrcaSessionId, + db: OrchestrationDb +): boolean { + return Boolean( + db.db + .prepare( + `SELECT 1 FROM dispatch_contexts + WHERE assignee_orca_session_id = ? AND process_incarnation = ? LIMIT 1` + ) + .get(sessionId, structuredWorkerProcessIncarnation(sessionId)) + ) +} + /** 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..624f6abba32 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -25,6 +25,7 @@ import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' import { isTerminalLeafId, makePaneKey, parsePaneKey } from '../../shared/stable-pane-id' +import { isOrcaSessionId, type OrcaSessionId } from '../../shared/orca-session-address' import { parseWorkerTerminalHostScope, type WorkerTerminalHostScope @@ -57,12 +58,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. */ @@ -122,6 +124,14 @@ export function sessionIdFromStructuredWorkerIncarnation( return sessionId.length > 0 ? sessionId : null } +/** The Orca session id a `structured:` incarnation names; null for any other. */ +export function structuredWorkerOrcaSessionIdForIncarnation( + processIncarnation: string | null | undefined +): OrcaSessionId | null { + const sessionId = sessionIdFromStructuredWorkerIncarnation(processIncarnation) + return sessionId !== null && isOrcaSessionId(sessionId) ? sessionId : 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/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) }) 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 +} 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