From a37b79849b17ec79c04e3a7754644b72b7ddef0e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:52:32 -0700 Subject: [PATCH 01/11] refactor(orchestration): give structured sessions an orchestration actor column Adds nullable session: actor columns to runs (coordinator) and dispatch_contexts (assignee, creator) at schema v42, a shared codec, a fill for rows that provably belong to a structured worker, and a coordinator mail-address cache that remembers a handle-less coordinator by its actor address. --- .../orchestration/db/contract-constants.ts | 3 +- .../orchestration/db/orchestration-db.ts | 2 + .../orchestration/db/row-column-lists.ts | 3 + .../orchestration/db/runs/run-binding.ts | 3 +- .../db/runs/run-coordinator-mail-routing.ts | 5 +- .../orchestration/db/runs/run-lookup.ts | 2 +- .../db/schema/create-core-tables-sql.ts | 4 + .../db/schema/create-graph-tables-sql.ts | 3 + .../orchestration/db/schema/migrate-v42.ts | 58 ++++++++ .../orchestration/db/schema/migrate.ts | 2 + .../structured-worker-actor-backfill.ts | 130 ++++++++++++++++++ .../orchestration-schema-version-skew.ts | 5 +- src/main/runtime/orchestration/types.ts | 6 + .../orchestration/runs/run-receipt.test.ts | 5 +- .../methods/orchestration/runs/run-receipt.ts | 3 +- .../runtime/structured-worker-identity.ts | 4 +- src/shared/orchestration-actor.ts | 52 +++++++ 17 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 src/main/runtime/orchestration/db/schema/migrate-v42.ts create mode 100644 src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts create mode 100644 src/shared/orchestration-actor.ts diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index a8ddf9994d0c..f065923d110e 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -18,4 +18,5 @@ export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION // Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity, v31 dispatch/resource identity links, v32 bounded worker-terminal recovery metadata, v33 durable mailbox pointer Enter state, v34 role-addressed mailbox deliveries, v35 mailbox delivery default and index-predicate repair, v36 dispatch mailbox consumer generation, v37 recorded dispatch creator identity, v39 structured session journal archives. // v41: derive outstanding deliveries from unread messages. -export const SCHEMA_VERSION = 41 +// v42: structured-session orchestration actor columns. +export const SCHEMA_VERSION = 42 diff --git a/src/main/runtime/orchestration/db/orchestration-db.ts b/src/main/runtime/orchestration/db/orchestration-db.ts index 1ce52e96c4a5..951b6f8b9ba6 100644 --- a/src/main/runtime/orchestration/db/orchestration-db.ts +++ b/src/main/runtime/orchestration/db/orchestration-db.ts @@ -9,6 +9,7 @@ import { } from './runs/run-coordinator-mail-routing' import { createTables } from './schema/create-tables' import { migrate } from './schema/migrate' +import { backfillStructuredWorkerActors } from './schema/structured-worker-actor-backfill' class OrchestrationDbCore { db: Database.Database @@ -30,6 +31,7 @@ class OrchestrationDbCore { createTables.call(this as unknown as OrchestrationDb) migrate.call(this as unknown as OrchestrationDb) backfillFederatedStubHomeRuns(this.db) + backfillStructuredWorkerActors(this.db) createCoordinatorMailRoutingTrigger.call(this as unknown as OrchestrationDb) rememberCurrentRunCoordinatorHandles.call(this as unknown as OrchestrationDb) hardenOrchestrationDatabaseFiles(dbPath) diff --git a/src/main/runtime/orchestration/db/row-column-lists.ts b/src/main/runtime/orchestration/db/row-column-lists.ts index 368c97ede3c2..a3480a0d79ba 100644 --- a/src/main/runtime/orchestration/db/row-column-lists.ts +++ b/src/main/runtime/orchestration/db/row-column-lists.ts @@ -13,6 +13,7 @@ export const RUN_COLUMNS = [ 'home_database', 'coordinator_handle', 'coordinator_pane_key', + 'coordinator_actor', 'consumer_generation', 'legacy', 'created_at', @@ -45,6 +46,7 @@ export const DISPATCH_CONTEXT_COLUMNS = [ 'launch_token_hash', 'assignee_handle', 'assignee_pane_key', + 'assignee_actor', 'capability_hash', 'process_incarnation', 'capability_revoked_at', @@ -52,6 +54,7 @@ export const DISPATCH_CONTEXT_COLUMNS = [ 'creator_dispatch_id', 'creator_handle', 'creator_pane_key', + 'creator_actor', 'host_scope', 'status', 'failure_count', diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index e2ff77ec8182..406b28ee7dfa 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -133,10 +133,11 @@ export function bindRun( this.setLegacyCompatibilityPrincipalStatus(coordinatorPrincipal.id, 'revoked') } } + // The actor belongs to the coordinator being replaced; nothing here resolves the new one's. this.db .prepare( `UPDATE runs - SET coordinator_handle = ?, coordinator_pane_key = ?, + SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_actor = NULL, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` 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 121a051cba3c..fe9e429b08e2 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 @@ -12,11 +12,12 @@ export function rememberRunCoordinatorHandle( .run(runId, terminalHandle) } +// A handle-less structured-session coordinator is remembered by its actor address (migrate-v42). export function rememberCurrentRunCoordinatorHandles(this: OrchestrationDb): void { this.db.exec(` INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - SELECT id, coordinator_handle FROM runs - WHERE legacy = 0 AND coordinator_handle IS NOT NULL + SELECT id, COALESCE(coordinator_handle, coordinator_actor) FROM runs + WHERE legacy = 0 AND COALESCE(coordinator_handle, coordinator_actor) IS NOT NULL `) } diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index 194cceffcf0c..3c6ce99bc411 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -135,7 +135,7 @@ export function unbindOtherRunsForPane( this.db .prepare( `UPDATE runs - SET coordinator_handle = NULL, coordinator_pane_key = NULL, + SET coordinator_handle = NULL, coordinator_pane_key = NULL, coordinator_actor = NULL, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` diff --git a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts index f02e7e8c15ac..bf6e5a6ad8f3 100644 --- a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts @@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS runs ( home_database TEXT NOT NULL DEFAULT 'this_database', coordinator_handle TEXT, coordinator_pane_key TEXT, + coordinator_actor TEXT, consumer_generation INTEGER NOT NULL DEFAULT 0, legacy INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -55,6 +56,9 @@ CREATE TABLE IF NOT EXISTS run_coordinator_handles ( CREATE INDEX IF NOT EXISTS idx_run_coordinator_handles_handle ON run_coordinator_handles(terminal_handle, run_id); +-- Handle-only on purpose; migrate-v42 replaces both triggers with the actor-aware form. This SQL +-- runs before migrate on every open, so it must compile against a pre-v42 runs table: a trigger +-- naming coordinator_actor there makes the next INSERT INTO runs fail to prepare mid-migration. CREATE TRIGGER IF NOT EXISTS trg_runs_remember_coordinator_insert AFTER INSERT ON runs WHEN NEW.legacy = 0 AND NEW.coordinator_handle IS NOT NULL diff --git a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts index d674a5298e72..bcabe83cbcb5 100644 --- a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts @@ -145,6 +145,8 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( launch_token_hash TEXT, assignee_handle TEXT, assignee_pane_key TEXT, + -- session: when the party is a structured session (orchestration-actor); NULL for a PTY. + assignee_actor TEXT, capability_hash TEXT, process_incarnation TEXT, capability_revoked_at TEXT, @@ -155,6 +157,7 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( -- so it must not count as a nesting parent. Null on rows written before v37 and for Orca's loop. creator_handle TEXT, creator_pane_key TEXT, + creator_actor TEXT, host_scope TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), diff --git a/src/main/runtime/orchestration/db/schema/migrate-v42.ts b/src/main/runtime/orchestration/db/schema/migrate-v42.ts new file mode 100644 index 000000000000..5ea27823ff70 --- /dev/null +++ b/src/main/runtime/orchestration/db/schema/migrate-v42.ts @@ -0,0 +1,58 @@ +import type { OrchestrationDb } from '../orchestration-db' +import { backfillStructuredWorkerActors } from './structured-worker-actor-backfill' + +const ACTOR_COLUMNS = [ + ['runs', 'coordinator_actor'], + ['dispatch_contexts', 'assignee_actor'], + ['dispatch_contexts', 'creator_actor'] +] as const + +/** + * Orchestration actor columns (`session:`, see orchestration-actor): who a Run's coordinator + * and a Dispatch's assignee and creator are when that party is a structured session. PTY rows keep + * NULL and keep their handle and pane-key identity. + * + * Dev databases stamped v42 by an earlier prototype hold `*_principal` columns instead. They are + * unsupported: the version-skew probe finds the actor columns missing and replays the chain, which + * adds these columns and leaves the stale ones unread. + */ +export function migrateV42(this: OrchestrationDb, current: number): void { + if (current >= 42) { + return + } + // Guarded because createTables runs first on every open and already gives a fresh database these. + for (const [table, column] of ACTOR_COLUMNS) { + if (!this.hasColumn(table, column)) { + this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} TEXT`) + } + } + this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_runs_coordinator_actor + ON runs(coordinator_actor) WHERE coordinator_actor IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_actor + ON dispatch_contexts(assignee_actor) WHERE assignee_actor IS NOT NULL; + `) + // A handle-less coordinator is remembered by its actor, which is already a mailbox address, so + // every reader of this cache matches it unchanged. This step owns the trigger form: the static + // createTables SQL must stay handle-only (see create-core-tables-sql), and CREATE TRIGGER IF NOT + // EXISTS never replaces an existing database's triggers, so they are dropped and recreated by name. + this.db.exec(` + DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_insert; + DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_update; + CREATE TRIGGER trg_runs_remember_coordinator_insert + AFTER INSERT ON runs + WHEN NEW.legacy = 0 AND COALESCE(NEW.coordinator_handle, NEW.coordinator_actor) IS NOT NULL + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + VALUES (NEW.id, COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)); + END; + CREATE TRIGGER trg_runs_remember_coordinator_update + AFTER UPDATE OF coordinator_handle, coordinator_actor ON runs + WHEN NEW.legacy = 0 AND COALESCE(NEW.coordinator_handle, NEW.coordinator_actor) IS NOT NULL + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + VALUES (NEW.id, COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)); + END; + `) + backfillStructuredWorkerActors(this.db) +} diff --git a/src/main/runtime/orchestration/db/schema/migrate.ts b/src/main/runtime/orchestration/db/schema/migrate.ts index bcf1b830ebc2..c02eeaf7cb38 100644 --- a/src/main/runtime/orchestration/db/schema/migrate.ts +++ b/src/main/runtime/orchestration/db/schema/migrate.ts @@ -12,6 +12,7 @@ import { migrateV38 } from './migrate-v38' import { migrateV39 } from './migrate-v39' import { migrateV40 } from './migrate-v40' import { DERIVED_DELIVERY_SCHEMA_SQL, migrateV41 } from './migrate-v41' +import { migrateV42 } from './migrate-v42' // Why: CREATE TABLE IF NOT EXISTS won't alter existing DBs; migrate in a txn that bumps user_version only on success (atomic all-or-nothing). export function migrate(this: OrchestrationDb): void { @@ -38,6 +39,7 @@ export function migrate(this: OrchestrationDb): void { migrateV40.call(this, current) // Why: older steps recreate the unique index; v41 must run after them. migrateV41.call(this, current) + migrateV42.call(this, current) this.createMailboxDeliveryIndexesIfPossible() // Why: rebuild steps above RENAME the table, which SQLite refuses while a view names it. this.db.exec(DERIVED_DELIVERY_SCHEMA_SQL) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts new file mode 100644 index 000000000000..ed49511d5845 --- /dev/null +++ b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts @@ -0,0 +1,130 @@ +import type Database from '../../../../sqlite/sync-database' +import { + formatOrchestrationActor, + sessionOrchestrationActor +} from '../../../../../shared/orchestration-actor' +import { + STRUCTURED_WORKER_HANDLE_PREFIX, + STRUCTURED_WORKER_INCARNATION_PREFIX, + isStructuredWorkerHandle, + sessionIdFromStructuredWorkerIncarnation +} from '../../../structured-worker-identity' + +// GLOB is a case-sensitive prefix filter; the canonical predicates still decide every row. +const HANDLE_GLOB = `${STRUCTURED_WORKER_HANDLE_PREFIX}*` +const INCARNATION_GLOB = `${STRUCTURED_WORKER_INCARNATION_PREFIX}*` + +const RECORDED_WORKER_SESSIONS_SQL = ` + SELECT assignee_handle AS handle, process_incarnation AS incarnation FROM dispatch_contexts + WHERE assignee_handle GLOB ? AND process_incarnation GLOB ? + UNION + SELECT terminal_handle, process_incarnation FROM worker_terminal_resources + WHERE terminal_handle GLOB ? AND process_incarnation GLOB ?` + +/** + * Fills the actor on rows that provably belong to a structured worker session and have none: a + * `structured:` process incarnation, or a `structworker_` handle this host recorded + * against such an incarnation. Every other row stays NULL, PTY rows included, and evidence naming + * more than one session proves none. Pane keys are never read: a pane outlives the agent in it. + * + * Both markers are minted only for a local, non-WSL session (`structuredWorkerHostScope`), so the + * rows carrying them were written by this host. + * + * Runs in the v42 migration and on every open, because a binary rolled back past v42 keeps writing + * structured-worker rows without an actor after user_version is already 42. It fills NULLs only, + * so an actor a writer recorded is never rewritten. + */ +export function backfillStructuredWorkerActors(db: Database.Database): void { + let recordedSessions: Map> | undefined + const actorFor = (handle: unknown, incarnation: unknown): string | null => { + if (incarnation != null && typeof incarnation !== 'string') { + return null + } + const sessions = new Set() + if (incarnation != null) { + const sessionId = sessionIdFromStructuredWorkerIncarnation(incarnation) + if (!sessionId) { + // A live non-structured incarnation says this row is some other process. + return null + } + sessions.add(sessionId) + } + if (typeof handle === 'string' && isStructuredWorkerHandle(handle)) { + recordedSessions ??= recordedWorkerSessionsByHandle(db) + for (const sessionId of recordedSessions.get(handle) ?? []) { + sessions.add(sessionId) + } + } + const [sessionId, ...others] = sessions + const actor = sessionId && others.length === 0 ? sessionOrchestrationActor(sessionId) : null + return actor ? formatOrchestrationActor(actor) : null + } + + const assignees = db + .prepare( + `SELECT id, assignee_handle, process_incarnation FROM dispatch_contexts + WHERE assignee_actor IS NULL AND (process_incarnation GLOB ? OR assignee_handle GLOB ?)` + ) + .all(INCARNATION_GLOB, HANDLE_GLOB) + const setAssignee = db.prepare( + 'UPDATE dispatch_contexts SET assignee_actor = ? WHERE id = ? AND assignee_actor IS NULL' + ) + for (const row of assignees) { + const actor = actorFor(row.assignee_handle, row.process_incarnation) + if (actor && typeof row.id === 'string') { + setAssignee.run(actor, row.id) + } + } + + const creators = db + .prepare( + `SELECT id, creator_handle FROM dispatch_contexts + WHERE creator_actor IS NULL AND creator_handle GLOB ?` + ) + .all(HANDLE_GLOB) + const setCreator = db.prepare( + 'UPDATE dispatch_contexts SET creator_actor = ? WHERE id = ? AND creator_actor IS NULL' + ) + for (const row of creators) { + const actor = actorFor(row.creator_handle, null) + if (actor && typeof row.id === 'string') { + setCreator.run(actor, row.id) + } + } + + const coordinators = db + .prepare( + `SELECT id, coordinator_handle FROM runs + WHERE coordinator_actor IS NULL AND coordinator_handle GLOB ?` + ) + .all(HANDLE_GLOB) + const setCoordinator = db.prepare( + 'UPDATE runs SET coordinator_actor = ? WHERE id = ? AND coordinator_actor IS NULL' + ) + for (const row of coordinators) { + const actor = actorFor(row.coordinator_handle, null) + if (actor && typeof row.id === 'string') { + setCoordinator.run(actor, row.id) + } + } +} + +function recordedWorkerSessionsByHandle(db: Database.Database): Map> { + const sessionsByHandle = new Map>() + const rows = db + .prepare(RECORDED_WORKER_SESSIONS_SQL) + .all(HANDLE_GLOB, INCARNATION_GLOB, HANDLE_GLOB, INCARNATION_GLOB) + for (const row of rows) { + const sessionId = + typeof row.incarnation === 'string' + ? sessionIdFromStructuredWorkerIncarnation(row.incarnation) + : null + if (typeof row.handle !== 'string' || !sessionId) { + continue + } + const sessions = sessionsByHandle.get(row.handle) ?? new Set() + sessions.add(sessionId) + sessionsByHandle.set(row.handle, sessions) + } + return sessionsByHandle +} diff --git a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts index 85e0a78b3ae4..f6194202c436 100644 --- a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts +++ b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts @@ -43,7 +43,10 @@ const VERSIONED_POST_V6_COLUMNS = [ { version: 36, table: 'remote_dispatch_attachments', column: 'consumer_generation' }, { version: 37, table: 'dispatch_contexts', column: 'creator_handle' }, { version: 37, table: 'dispatch_contexts', column: 'creator_pane_key' }, - { version: 40, table: 'remote_dispatch_attachments', column: 'home_run_id' } + { version: 40, table: 'remote_dispatch_attachments', column: 'home_run_id' }, + { version: 42, table: 'runs', column: 'coordinator_actor' }, + { version: 42, table: 'dispatch_contexts', column: 'assignee_actor' }, + { version: 42, table: 'dispatch_contexts', column: 'creator_actor' } ] as const // Why: v34 shipped without these two, so a v34 stamp proves nothing about them; v35 repairs both diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 85d5dcfc1596..2bbbb6a9ebff 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -46,6 +46,8 @@ export type RunRow = { home_database: string coordinator_handle: string | null coordinator_pane_key: string | null + /** `session:` for a structured-session coordinator; NULL for a PTY one. See orchestration-actor. */ + coordinator_actor: string | null consumer_generation: number legacy: number created_at: string @@ -278,6 +280,8 @@ export type DispatchContextRow = { launch_token_hash: string | null assignee_handle: string | null assignee_pane_key: string | null + /** `session:` when the assignee is a structured session; NULL for a PTY. */ + assignee_actor: string | null capability_hash: string | null process_incarnation: string | null capability_revoked_at: string | null @@ -287,6 +291,8 @@ export type DispatchContextRow = { /** Creator identity; equal to the assignee means a self-dispatch, which adds no nesting depth. */ creator_handle: string | null creator_pane_key: string | null + /** `session:` when the creator is a structured session; NULL for a PTY or Orca's loop. */ + creator_actor: string | null host_scope: string | null status: DispatchStatus failure_count: number 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 83434c631194..cb68ed484534 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 @@ -9,6 +9,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_actor: 'session:22222222-2222-4222-8222-222222222222', consumer_generation: 3, legacy: 0, created_at: '2026-09-04T18:53:07Z', @@ -30,6 +31,7 @@ describe('exposeRun', () => { ]) expect(exposed).not.toHaveProperty('home_database') expect(exposed).not.toHaveProperty('coordinator_pane_key') + expect(exposed).not.toHaveProperty('coordinator_actor') }) it('preserves every published column by value', () => { @@ -54,8 +56,9 @@ describe('exposeRun', () => { }) it('strips the columns even when they are null', () => { - const exposed = exposeRun({ ...RUN_ROW, coordinator_pane_key: null }) + const exposed = exposeRun({ ...RUN_ROW, coordinator_pane_key: null, coordinator_actor: null }) expect(exposed).not.toHaveProperty('coordinator_pane_key') + expect(exposed).not.toHaveProperty('coordinator_actor') }) }) diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts index 30a22e2fc18c..35a50c4d8c42 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts @@ -1,7 +1,8 @@ import type { RunRow } from '../../../../orchestration/types' // Why: home_database and coordinator_pane_key are runtime routing state; no caller reads them. -const INTERNAL_RUN_COLUMNS = ['home_database', 'coordinator_pane_key'] as const +// coordinator_actor stays off the wire until a reader needs it; publishing it is a wire change. +const INTERNAL_RUN_COLUMNS = ['home_database', 'coordinator_pane_key', 'coordinator_actor'] as const export type RunReceipt = Omit diff --git a/src/main/runtime/structured-worker-identity.ts b/src/main/runtime/structured-worker-identity.ts index b29d68c297a7..ef2fec6a8fa4 100644 --- a/src/main/runtime/structured-worker-identity.ts +++ b/src/main/runtime/structured-worker-identity.ts @@ -32,8 +32,8 @@ import { // Deliberately not `term_`: `issueHandle` revalidates the renderer graph epoch against the // renderer-driven leaves map, so a main-minted `term_` leaf evaporates on the next window reload. -const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' -const STRUCTURED_WORKER_INCARNATION_PREFIX = 'structured:' +export const STRUCTURED_WORKER_HANDLE_PREFIX = 'structworker_' +export const STRUCTURED_WORKER_INCARNATION_PREFIX = 'structured:' export type StructuredWorkerIdentity = { handle: string diff --git a/src/shared/orchestration-actor.ts b/src/shared/orchestration-actor.ts new file mode 100644 index 000000000000..c2a499191c00 --- /dev/null +++ b/src/shared/orchestration-actor.ts @@ -0,0 +1,52 @@ +import { isAgentSessionId } from './agent-session-record' + +/** + * An orchestration party that is not a terminal, as `(kind, id)`. Stored in the `…_actor` columns + * and addressed in the mailbox namespace as `:`, beside `run:` and `dispatch:`, + * so the stored value and the address are one spelling. + * + * The only kind is a structured session, keyed by the id Orca minted for it — never the provider's + * id, which rotates on `/clear`. Where the session runs is not part of the key; it is read from the + * session record when needed. PTY agents have no actor: a pane outlives the agent in it, so a + * pane-keyed actor would be inherited by the pane's next occupant. + */ +const ACTOR_ID_PREDICATES = { + session: isAgentSessionId +} as const satisfies Record boolean> + +export type OrchestrationActorKind = keyof typeof ACTOR_ID_PREDICATES + +export type OrchestrationActor = { kind: OrchestrationActorKind; id: string } + +function isOrchestrationActorKind(kind: string): kind is OrchestrationActorKind { + return Object.hasOwn(ACTOR_ID_PREDICATES, kind) +} + +export function formatOrchestrationActor(actor: OrchestrationActor): string { + return `${actor.kind}:${actor.id}` +} + +/** The stored and addressed spelling only. An unknown kind reads as null, never as a session. */ +export function parseOrchestrationActor( + value: string | null | undefined +): OrchestrationActor | null { + const separator = value?.indexOf(':') ?? -1 + if (!value || separator <= 0) { + return null + } + const kind = value.slice(0, separator) + const id = value.slice(separator + 1) + return isOrchestrationActorKind(kind) && ACTOR_ID_PREDICATES[kind](id) ? { kind, id } : null +} + +export function sessionOrchestrationActor(sessionId: string): OrchestrationActor | null { + return isAgentSessionId(sessionId) ? { kind: 'session', id: sessionId } : null +} + +/** + * For input already known to name a session: its address, or its bare Orca session id. Not for a + * recipient slot, where a bare string is a terminal handle. + */ +export function normalizeOrchestrationActor(value: string): OrchestrationActor | null { + return parseOrchestrationActor(value) ?? sessionOrchestrationActor(value) +} From f53dd71ae364217698f8d3f86caab7b7e864ad4d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:57:50 -0700 Subject: [PATCH 02/11] test(orchestration): pin the actor columns, their fill, cache and v40/v41 upgrade paths --- .../orchestration/db/dispatch-depth.test.ts | 49 ++ .../structured-worker-actor-backfill.test.ts | 120 +++++ ...chestration-actor-column-migration.test.ts | 424 ++++++++++++++++++ .../run-coordinator-actor-address.test.ts | 154 +++++++ src/shared/orchestration-actor.test.ts | 54 +++ 5 files changed, 801 insertions(+) create mode 100644 src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts create mode 100644 src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts create mode 100644 src/main/runtime/orchestration/run-coordinator-actor-address.test.ts create mode 100644 src/shared/orchestration-actor.test.ts diff --git a/src/main/runtime/orchestration/db/dispatch-depth.test.ts b/src/main/runtime/orchestration/db/dispatch-depth.test.ts index 013cedffe91b..408190b0fd7e 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.test.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.test.ts @@ -1,6 +1,12 @@ import { afterEach, describe, expect, it } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../../structured-worker-identity' import { OrchestrationDb } from '../db' import { AmbiguousDispatchParentError } from './dispatch-depth' +import { backfillStructuredWorkerActors } from './schema/structured-worker-actor-backfill' /** * These pin the fence Orca documented but never enforced: before this feature a @@ -283,4 +289,47 @@ describe('nested worker depth', () => { expect(row.process_incarnation).toBeNull() expect(db.resolveCreatorDepth({ kind: 'terminal', handle: 'term_ctx' })).toBe(1) }) + + // Pinned for the reader that switches self-dispatch detection to actor equality: equal creator + // and assignee actors must keep meaning bookkeeping, and different ones delegation. + it('records equal actors exactly when a structured session dispatches to itself', () => { + db = new OrchestrationDb(':memory:') + const sessionId = '5c7e9a1d-3f6b-4c8e-8d2a-4b6c8e0a2d36' + const self = { + kind: 'terminal', + handle: mintStructuredWorkerHandle(), + paneKey: mintStructuredWorkerPaneKey(sessionId) + } as const + const own = db.createDispatchContext({ + taskId: db.createTask({ runId: 'run_legacy_local', spec: 'own bookkeeping' }).id, + assigneeHandle: self.handle, + assigneePaneKey: self.paneKey, + processIncarnation: structuredWorkerProcessIncarnation(sessionId), + creator: self, + maxDepth: UNCAPPED + }) + const delegated = db.createDispatchContext({ + taskId: db.createTask({ runId: 'run_legacy_local', spec: 'delegated' }).id, + assigneeHandle: 'term_delegate', + assigneePaneKey: 'tab_delegate:22222222-2222-4222-8222-222222222222', + creator: self, + maxDepth: UNCAPPED + }) + backfillStructuredWorkerActors(db.db) + + const ownRow = db.getDispatchContextById(own.id) + expect(ownRow?.creator_actor).toBe(`session:${sessionId}`) + expect(ownRow?.assignee_actor).toBe(ownRow?.creator_actor) + expect(db.resolveCreatorDepth(self)).toBe(0) + const delegatedRow = db.getDispatchContextById(delegated.id) + expect(delegatedRow?.creator_actor).toBe(`session:${sessionId}`) + expect(delegatedRow?.assignee_actor).toBeNull() + expect( + db.resolveCreatorDepth({ + kind: 'terminal', + handle: 'term_delegate', + paneKey: 'tab_delegate:22222222-2222-4222-8222-222222222222' + }) + ).toBe(1) + }) }) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts new file mode 100644 index 000000000000..fbacd3675ffa --- /dev/null +++ b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../../../structured-worker-identity' +import { OrchestrationDb } from '../orchestration-db' +import { backfillStructuredWorkerActors } from './structured-worker-actor-backfill' + +const SESSION_A = '0d2f4b6a-8c1e-4a3b-9d5f-7e0a2c4b6d81' +const SESSION_B = '1e3a5c7b-9d2f-4b4c-8e6a-0f1b3d5c7e92' +const SESSION_C = '2f4b6d8c-0e3a-4c5d-9f7b-1a2c4e6d8fa3' +const UNCAPPED = Number.MAX_SAFE_INTEGER +const SYSTEM = { kind: 'system' } as const + +describe('structured worker actor backfill', () => { + let db: OrchestrationDb + + afterEach(() => db?.close()) + + function dispatch(params: { + handle: string + paneKey: string + incarnation?: string + creator?: { kind: 'terminal'; handle: string; paneKey: string } + }): string { + const task = db.createTask({ runId: 'run_legacy_local', spec: `work for ${params.handle}` }) + return db.createDispatchContext({ + taskId: task.id, + assigneeHandle: params.handle, + assigneePaneKey: params.paneKey, + processIncarnation: params.incarnation, + creator: params.creator ?? SYSTEM, + maxDepth: UNCAPPED + }).id + } + + function actors(dispatchId: string): { assignee: string | null; creator: string | null } { + const row = db.getDispatchContextById(dispatchId) + return { assignee: row?.assignee_actor ?? null, creator: row?.creator_actor ?? null } + } + + it('proves a handle through the session this host recorded against it', () => { + db = new OrchestrationDb(':memory:') + const handle = mintStructuredWorkerHandle() + const pane = mintStructuredWorkerPaneKey(SESSION_B) + // The worker's own row carries no incarnation; its terminal resource row does. + const handleOnly = dispatch({ handle, paneKey: pane }) + db.createWorkerTerminalResourceStatement({ + dispatchId: handleOnly, + worktreeId: 'wt_1', + terminalHandle: handle, + paneKey: pane, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_B), + hostScope: JSON.stringify({ kind: 'local', hostId: 'local' }), + ownership: 'owned' + }) + + backfillStructuredWorkerActors(db.db) + + expect(actors(handleOnly)).toEqual({ assignee: `session:${SESSION_B}`, creator: null }) + }) + + it('leaves every row it cannot tie to exactly one valid session NULL', () => { + db = new OrchestrationDb(':memory:') + const unrecorded = mintStructuredWorkerHandle() + const noRecord = dispatch({ + handle: unrecorded, + paneKey: mintStructuredWorkerPaneKey(SESSION_A) + }) + const invalidId = dispatch({ + handle: mintStructuredWorkerHandle(), + paneKey: 'tab_x:44444444-4444-4444-8444-444444444444', + incarnation: 'structured:not a session id' + }) + // A terminal in a structured session's tab: the pane key names a session, the process does not. + const terminalInSessionTab = dispatch({ + handle: 'term_tui', + paneKey: mintStructuredWorkerPaneKey(SESSION_C), + incarnation: 'pty_proc_1a2b:777' + }) + const shared = mintStructuredWorkerHandle() + const conflicting = dispatch({ + handle: shared, + paneKey: mintStructuredWorkerPaneKey(SESSION_A), + incarnation: structuredWorkerProcessIncarnation(SESSION_A) + }) + db.createWorkerTerminalResourceStatement({ + dispatchId: conflicting, + worktreeId: 'wt_1', + terminalHandle: shared, + paneKey: null, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_B), + ownership: 'owned' + }) + + backfillStructuredWorkerActors(db.db) + + for (const id of [noRecord, invalidId, terminalInSessionTab, conflicting]) { + expect(actors(id), id).toEqual({ assignee: null, creator: null }) + } + }) + + it('fills only NULLs and never rewrites an actor a writer recorded', () => { + db = new OrchestrationDb(':memory:') + const handle = mintStructuredWorkerHandle() + const id = dispatch({ + handle, + paneKey: mintStructuredWorkerPaneKey(SESSION_A), + incarnation: structuredWorkerProcessIncarnation(SESSION_A) + }) + db.db + .prepare('UPDATE dispatch_contexts SET assignee_actor = ? WHERE id = ?') + .run(`session:${SESSION_C}`, id) + + backfillStructuredWorkerActors(db.db) + + expect(actors(id).assignee).toBe(`session:${SESSION_C}`) + }) +}) diff --git a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts new file mode 100644 index 000000000000..538de2761489 --- /dev/null +++ b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts @@ -0,0 +1,424 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { OrchestrationDb } from './db' +import { SCHEMA_VERSION } from './db/contract-constants' +import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' + +const SESSION_ID = '5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11' +const SESSION_ACTOR = `session:${SESSION_ID}` +const CHAT_SESSION_ACTOR = 'session:9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93' +const COORDINATOR_PANE = 'tab_coord:11111111-1111-4111-8111-111111111111' +const PTY_WORKER_PANE = 'tab_pty:22222222-2222-4222-8222-222222222222' +const NESTED_PANE = 'tab_nested:33333333-3333-4333-8333-333333333333' +const UNCAPPED = Number.MAX_SAFE_INTEGER + +// The coordinator-address triggers exactly as main stamped them at v41 and before. +const HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL = ` + CREATE TRIGGER trg_runs_remember_coordinator_insert + AFTER INSERT ON runs + WHEN NEW.legacy = 0 AND NEW.coordinator_handle IS NOT NULL + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + VALUES (NEW.id, NEW.coordinator_handle); + END; + CREATE TRIGGER trg_runs_remember_coordinator_update + AFTER UPDATE OF coordinator_handle ON runs + WHEN NEW.legacy = 0 AND NEW.coordinator_handle IS NOT NULL + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + VALUES (NEW.id, NEW.coordinator_handle); + END;` + +const V41_RUN_COLUMNS = + 'id, objective, home_database, coordinator_handle, coordinator_pane_key, consumer_generation, legacy, created_at, updated_at' + +type SeededRows = { + ptyRunId: string + structuredRunId: string + structuredDispatchId: string + ptyDispatchId: string + nestedDispatchId: string + workerHandle: string +} + +/** One PTY coordinator with a structured worker and a PTY worker; the structured worker runs a nested Run. */ +function seedStructuredAndPtyRows(db: OrchestrationDb): SeededRows { + const workerHandle = mintStructuredWorkerHandle() + const workerPane = mintStructuredWorkerPaneKey(SESSION_ID) + const coordinator = { kind: 'terminal', handle: 'term_coord', paneKey: COORDINATOR_PANE } as const + const ptyRun = db.createRun({ + objective: 'pty coordinator', + coordinatorHandle: 'term_coord', + coordinatorPaneKey: COORDINATOR_PANE + }) + const structuredDispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: ptyRun.id, spec: 'structured worker' }).id, + assigneeHandle: workerHandle, + assigneePaneKey: workerPane, + processIncarnation: structuredWorkerProcessIncarnation(SESSION_ID), + creator: coordinator, + maxDepth: UNCAPPED + }) + const ptyDispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: ptyRun.id, spec: 'pty worker' }).id, + assigneeHandle: 'term_pty_worker', + assigneePaneKey: PTY_WORKER_PANE, + processIncarnation: 'pty_proc_7f3a:4242', + creator: coordinator, + maxDepth: UNCAPPED + }) + const structuredRun = db.createRun({ + objective: 'structured worker coordinates a nested run', + coordinatorHandle: workerHandle, + coordinatorPaneKey: workerPane + }) + const nestedDispatch = db.createDispatchContext({ + taskId: db.createTask({ runId: structuredRun.id, spec: 'nested' }).id, + assigneeHandle: 'term_nested', + assigneePaneKey: NESTED_PANE, + creator: { kind: 'terminal', handle: workerHandle, paneKey: workerPane }, + maxDepth: UNCAPPED + }) + return { + ptyRunId: ptyRun.id, + structuredRunId: structuredRun.id, + structuredDispatchId: structuredDispatch.id, + ptyDispatchId: ptyDispatch.id, + nestedDispatchId: nestedDispatch.id, + workerHandle + } +} + +/** Strips a current database back to the shape main stamps at v41 and stamps `version`. */ +function stripActorSchema(path: string, version: number): void { + const raw = new Database(path) + raw.exec(` + DROP INDEX idx_runs_coordinator_actor; + DROP INDEX idx_dispatch_assignee_actor; + DROP TRIGGER trg_runs_remember_coordinator_insert; + DROP TRIGGER trg_runs_remember_coordinator_update; + ALTER TABLE runs DROP COLUMN coordinator_actor; + ALTER TABLE dispatch_contexts DROP COLUMN assignee_actor; + ALTER TABLE dispatch_contexts DROP COLUMN creator_actor; + ${HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL} + `) + raw.pragma(`user_version = ${version}`) + raw.close() +} + +function coordinatorTriggerSql(db: Database.Database): string[] { + return db + .prepare( + `SELECT sql FROM sqlite_master WHERE type = 'trigger' + AND name IN ('trg_runs_remember_coordinator_insert', 'trg_runs_remember_coordinator_update') + ORDER BY name` + ) + .all() + .map((row) => String(row.sql)) +} + +function actorColumns(db: Database.Database): string[] { + return db + .prepare( + `SELECT name FROM pragma_table_info('runs') + UNION ALL SELECT name FROM pragma_table_info('dispatch_contexts')` + ) + .all() + .map((column) => String(column.name)) + .filter((name) => name.endsWith('_actor')) + .sort() +} + +function coordinatorAddresses(db: Database.Database, runIds: string[]): string[] { + return db + .prepare( + `SELECT run_id, terminal_handle FROM run_coordinator_handles + WHERE run_id IN (${runIds.map(() => '?').join(', ')})` + ) + .all(...runIds) + .map((row) => `${String(row.run_id)} ${String(row.terminal_handle)}`) + .sort() +} + +describe('orchestration actor column migration', () => { + const tempRoots: string[] = [] + + afterEach(() => { + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + function tempDbPath(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-actor-column-migration-')) + tempRoots.push(root) + return join(root, 'orchestration.db') + } + + it('starts a v41 database at v41 and gives exactly its structured-worker rows an actor', () => { + const path = tempDbPath() + const seed = new OrchestrationDb(path) + const rows = seedStructuredAndPtyRows(seed) + seed.close() + stripActorSchema(path, 41) + + const probe = new Database(path) + try { + // Why: a v42 skew entry registered under v41 makes this 6 and replays the whole chain. + expect(resolveOrchestrationMigrationStartVersion(probe, 41, SCHEMA_VERSION)).toBe(41) + } finally { + probe.close() + } + + const db = new OrchestrationDb(path) + try { + expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect(db.getDispatchContextById(rows.structuredDispatchId)).toMatchObject({ + assignee_actor: SESSION_ACTOR, + creator_actor: null + }) + expect(db.getDispatchContextById(rows.ptyDispatchId)).toMatchObject({ + assignee_actor: null, + creator_actor: null + }) + expect(db.getDispatchContextById(rows.nestedDispatchId)).toMatchObject({ + assignee_actor: null, + creator_actor: SESSION_ACTOR + }) + expect( + db.db.prepare('SELECT id FROM dispatch_contexts WHERE assignee_actor IS NOT NULL').all() + ).toEqual([{ id: rows.structuredDispatchId }]) + expect(db.getRunRaw(rows.ptyRunId)?.coordinator_actor).toBeNull() + expect(db.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) + // A handle-bearing coordinator stays remembered by its handle alone, as before v42. + expect(coordinatorAddresses(db.db, [rows.ptyRunId, rows.structuredRunId])).toEqual( + [`${rows.ptyRunId} term_coord`, `${rows.structuredRunId} ${rows.workerHandle}`].sort() + ) + // CREATE TRIGGER IF NOT EXISTS alone would have kept the handle-only form here. + for (const sql of coordinatorTriggerSql(db.db)) { + expect(sql).toContain('COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)') + } + } finally { + db.close() + } + }) + + it('starts a v40 database at v40 and runs v41 before v42', () => { + const path = tempDbPath() + const seed = new OrchestrationDb(path) + const rows = seedStructuredAndPtyRows(seed) + seed.close() + stripActorSchema(path, 40) + const raw = new Database(path) + // The v40 shape of the one object v41 changed: a unique outstanding-delivery index. + raw.exec(` + DROP TRIGGER trg_deliveries_one_outstanding; + DROP VIEW outstanding_deliveries; + DROP INDEX idx_deliveries_one_outstanding; + CREATE UNIQUE INDEX idx_deliveries_one_outstanding + ON deliveries(mailbox_handle) WHERE status = 'outstanding' AND mailbox_handle != ''; + `) + try { + expect(resolveOrchestrationMigrationStartVersion(raw, 40, SCHEMA_VERSION)).toBe(40) + } finally { + raw.close() + } + + const db = new OrchestrationDb(path) + try { + expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect(actorColumns(db.db)).toEqual(['assignee_actor', 'coordinator_actor', 'creator_actor']) + const index = db.db + .prepare("SELECT sql FROM sqlite_master WHERE name = 'idx_deliveries_one_outstanding'") + .get() + expect(String(index?.sql)).not.toContain('UNIQUE') + expect(db.getDispatchContextById(rows.structuredDispatchId)?.assignee_actor).toBe( + SESSION_ACTOR + ) + expect(db.getDispatchContextById(rows.ptyDispatchId)?.assignee_actor).toBeNull() + } finally { + db.close() + } + }) + + it('lets a v41 binary read and write a v42 database with actors in it', () => { + const path = tempDbPath() + const seed = new OrchestrationDb(path) + const rows = seedStructuredAndPtyRows(seed) + seed.close() + const upgraded = new OrchestrationDb(path) + expect(upgraded.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) + upgraded.db + .prepare( + `INSERT INTO runs (id, objective, coordinator_actor, consumer_generation, legacy) + VALUES ('run_session', 'session coordinator', ?, 1, 0)` + ) + .run(CHAT_SESSION_ACTOR) + upgraded.close() + + // A raw connection stands in for the v41 binary; each statement below is v41's own SQL. + const v41 = new Database(path) + try { + // v41's migrate returns early on a newer stamp, so nothing rewrites the v42 objects. + expect(resolveOrchestrationMigrationStartVersion(v41, SCHEMA_VERSION, 41)).toBe( + SCHEMA_VERSION + ) + expect( + v41.prepare(`SELECT ${V41_RUN_COLUMNS} FROM runs WHERE id = ?`).get('run_session') + ).toMatchObject({ id: 'run_session', coordinator_handle: null, coordinator_pane_key: null }) + expect( + v41.prepare(`SELECT ${V41_RUN_COLUMNS} FROM runs WHERE id = ?`).get(rows.ptyRunId) + ).toMatchObject({ coordinator_handle: 'term_coord', coordinator_pane_key: COORDINATOR_PANE }) + // v41's listRuns reads `SELECT *`; the extra column rides along and every v41 column is intact. + const listed = v41.prepare('SELECT * FROM runs ORDER BY created_at DESC, id DESC').all() + expect(listed.map((run) => run.id).sort()).toEqual( + ['run_legacy_local', 'run_session', rows.ptyRunId, rows.structuredRunId].sort() + ) + + // v41's createTables runs these on every open. + v41.exec( + HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL.replaceAll( + 'CREATE TRIGGER', + 'CREATE TRIGGER IF NOT EXISTS' + ) + ) + v41 + .prepare( + `INSERT INTO runs (id, objective, coordinator_handle, coordinator_pane_key, consumer_generation, legacy) + VALUES ('run_v41', 'written by v41', 'term_v41', 'tab_v41:44444444-4444-4444-8444-444444444444', 1, 0)` + ) + .run() + // An older binary rebinding a structured-coordinated Run cannot clear an actor it cannot see. + v41 + .prepare( + `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run('term_taker', PTY_WORKER_PANE, rows.structuredRunId) + v41.exec(`INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + SELECT id, coordinator_handle FROM runs WHERE legacy = 0 AND coordinator_handle IS NOT NULL`) + + expect(v41.prepare('SELECT coordinator_actor FROM runs WHERE id = ?').get('run_v41')).toEqual( + { + coordinator_actor: null + } + ) + expect( + v41.prepare('SELECT coordinator_actor FROM runs WHERE id = ?').get(rows.structuredRunId) + ).toEqual({ coordinator_actor: SESSION_ACTOR }) + expect(coordinatorAddresses(v41, ['run_v41', rows.structuredRunId])).toEqual( + [ + `${rows.structuredRunId} ${rows.workerHandle}`, + `${rows.structuredRunId} term_taker`, + 'run_v41 term_v41' + ].sort() + ) + // The v42 trigger form survives v41's IF NOT EXISTS create. + for (const sql of coordinatorTriggerSql(v41)) { + expect(sql).toContain('coordinator_actor') + } + } finally { + v41.close() + } + + const rolledForward = new OrchestrationDb(path) + try { + expect(rolledForward.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect(rolledForward.getRun('run_v41')?.coordinator_handle).toBe('term_v41') + expect(rolledForward.getRunMailboxOwnerIdsForHandle(CHAT_SESSION_ACTOR)).toEqual([ + 'run_session' + ]) + } finally { + rolledForward.close() + } + }) + + 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 actor yet, which is also the shape a binary rolled back past v42 writes. + const rows = seedStructuredAndPtyRows(first) + expect(first.getDispatchContextById(rows.structuredDispatchId)?.assignee_actor).toBeNull() + first.close() + + const reopened = new OrchestrationDb(path) + try { + expect(reopened.getDispatchContextById(rows.structuredDispatchId)?.assignee_actor).toBe( + SESSION_ACTOR + ) + expect(reopened.getDispatchContextById(rows.nestedDispatchId)?.creator_actor).toBe( + SESSION_ACTOR + ) + expect(reopened.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) + expect(reopened.getDispatchContextById(rows.ptyDispatchId)?.assignee_actor).toBeNull() + } finally { + reopened.close() + } + }) + + it('drives a dev database stamped v42 with prototype principal columns to add the actors', () => { + const path = tempDbPath() + const seed = new OrchestrationDb(path) + const rows = seedStructuredAndPtyRows(seed) + seed.close() + stripActorSchema(path, 41) + const raw = new Database(path) + // An unmerged prototype stamped v42 with differently named columns and triggers over them. + raw.exec(` + ALTER TABLE runs ADD COLUMN coordinator_principal TEXT; + ALTER TABLE dispatch_contexts ADD COLUMN assignee_principal TEXT; + ALTER TABLE dispatch_contexts ADD COLUMN creator_principal TEXT; + ALTER TABLE worker_terminal_resources ADD COLUMN principal TEXT; + DROP TRIGGER trg_runs_remember_coordinator_insert; + DROP TRIGGER trg_runs_remember_coordinator_update; + CREATE TRIGGER trg_runs_remember_coordinator_insert + AFTER INSERT ON runs + WHEN NEW.legacy = 0 AND (NEW.coordinator_handle IS NOT NULL OR NEW.coordinator_principal IS NOT NULL) + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + VALUES (NEW.id, COALESCE(NEW.coordinator_handle, NEW.coordinator_principal)); + END; + CREATE TRIGGER trg_runs_remember_coordinator_update + AFTER UPDATE OF coordinator_handle, coordinator_principal ON runs + WHEN NEW.legacy = 0 AND (NEW.coordinator_handle IS NOT NULL OR NEW.coordinator_principal IS NOT NULL) + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + VALUES (NEW.id, COALESCE(NEW.coordinator_handle, NEW.coordinator_principal)); + END; + `) + raw.pragma('user_version = 42') + try { + expect(resolveOrchestrationMigrationStartVersion(raw, 42, SCHEMA_VERSION)).toBe(6) + } finally { + raw.close() + } + + const db = new OrchestrationDb(path) + try { + expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect(actorColumns(db.db)).toEqual(['assignee_actor', 'coordinator_actor', 'creator_actor']) + expect(coordinatorTriggerSql(db.db).join('\n')).not.toContain('principal') + expect(db.getDispatchContextById(rows.structuredDispatchId)?.assignee_actor).toBe( + SESSION_ACTOR + ) + expect(() => + db.createRun({ + objective: 'after the replay', + coordinatorHandle: 'term_after', + coordinatorPaneKey: 'tab_after:55555555-5555-4555-8555-555555555555' + }) + ).not.toThrow() + } finally { + db.close() + } + }) +}) diff --git a/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts b/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts new file mode 100644 index 000000000000..344eaf9b08aa --- /dev/null +++ b/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts @@ -0,0 +1,154 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + formatOrchestrationActor, + parseOrchestrationActor +} from '../../../shared/orchestration-actor' +import { + mintStructuredWorkerHandle, + mintStructuredWorkerPaneKey, + structuredWorkerProcessIncarnation +} from '../structured-worker-identity' +import { OrchestrationDb } from './db' +import { backfillStructuredWorkerActors } from './db/schema/structured-worker-actor-backfill' + +const CHAT_SESSION_ID = '3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14' +const CHAT_ACTOR = formatOrchestrationActor({ kind: 'session', id: CHAT_SESSION_ID }) +const WORKER_SESSION_ID = '4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25' +const PTY_PANE = 'tab_pty:66666666-6666-4666-8666-666666666666' + +function addressesFor(db: OrchestrationDb, runId: string): string[] { + return db.db + .prepare('SELECT terminal_handle FROM run_coordinator_handles WHERE run_id = ?') + .all(runId) + .map((row) => String(row.terminal_handle)) + .sort() +} + +/** A handle-less coordinator row; no writer records one until the caller resolver lands. */ +function insertSessionCoordinatedRun(db: OrchestrationDb, runId: string): void { + db.db + .prepare( + `INSERT INTO runs (id, objective, coordinator_actor, consumer_generation, legacy) + VALUES (?, 'coordinated by a structured session', ?, 1, 0)` + ) + .run(runId, CHAT_ACTOR) +} + +describe('Run coordinator actor address', () => { + let db: OrchestrationDb | undefined + const tempRoots: string[] = [] + + afterEach(() => { + db?.close() + db = undefined + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('remembers a handle-less session coordinator by its actor address', () => { + db = new OrchestrationDb(':memory:') + insertSessionCoordinatedRun(db, 'run_session') + + const stored = db.getRunRaw('run_session')?.coordinator_actor ?? null + const actor = parseOrchestrationActor(stored) + expect(actor).toEqual({ kind: 'session', id: CHAT_SESSION_ID }) + expect(actor && formatOrchestrationActor(actor)).toBe(stored) + expect(addressesFor(db, 'run_session')).toEqual([CHAT_ACTOR]) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_ACTOR)).toEqual(['run_session']) + // The existing routing trigger matches the address by string equality, unchanged. + const reply = db.insertMessage({ + runId: 'run_session', + from: 'term_worker', + to: CHAT_ACTOR, + subject: 'done', + type: 'worker_done' + }) + expect(db.getMessageById(reply.id)?.to_handle).toBe('run:run_session') + }) + + it('remembers an actor bound by update, and again on reopen when the cache row is gone', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-actor-')) + tempRoots.push(root) + const path = join(root, 'orchestration.db') + db = new OrchestrationDb(path) + db.db + .prepare( + `INSERT INTO runs (id, objective, consumer_generation, legacy) + VALUES ('run_unbound', 'bound later', 1, 0)` + ) + .run() + db.db + .prepare('UPDATE runs SET coordinator_actor = ? WHERE id = ?') + .run(CHAT_ACTOR, 'run_unbound') + expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ACTOR]) + db.db.prepare('DELETE FROM run_coordinator_handles WHERE run_id = ?').run('run_unbound') + db.close() + + db = new OrchestrationDb(path) + expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ACTOR]) + }) + + it('keeps PTY coordinators remembered by handle alone', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'pty', + coordinatorHandle: 'term_first', + coordinatorPaneKey: PTY_PANE + }) + db.bindRun({ + runId: run.id, + coordinatorHandle: 'term_second', + coordinatorPaneKey: 'tab_second:77777777-7777-4777-8777-777777777777' + }) + + expect(db.getRunRaw(run.id)?.coordinator_actor).toBeNull() + expect(addressesFor(db, run.id)).toEqual(['term_first', 'term_second']) + }) + + it("never leaves a replaced structured coordinator's actor on the Run", () => { + db = new OrchestrationDb(':memory:') + const handle = mintStructuredWorkerHandle() + const pane = mintStructuredWorkerPaneKey(WORKER_SESSION_ID) + const ownTask = db.createTask({ runId: 'run_legacy_local', spec: 'structured worker' }) + db.createDispatchContext({ + taskId: ownTask.id, + assigneeHandle: handle, + assigneePaneKey: pane, + processIncarnation: structuredWorkerProcessIncarnation(WORKER_SESSION_ID), + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + const first = db.createRun({ + objective: 'first', + coordinatorHandle: handle, + coordinatorPaneKey: pane + }) + backfillStructuredWorkerActors(db.db) + expect(db.getRunRaw(first.id)?.coordinator_actor).toBe(`session:${WORKER_SESSION_ID}`) + + // A second Run from the same pane unbinds the first. + const second = db.createRun({ + objective: 'second', + coordinatorHandle: handle, + coordinatorPaneKey: pane + }) + expect(db.getRunRaw(first.id)).toMatchObject({ + coordinator_handle: null, + coordinator_actor: null + }) + + backfillStructuredWorkerActors(db.db) + expect(db.getRunRaw(second.id)?.coordinator_actor).toBe(`session:${WORKER_SESSION_ID}`) + db.bindRun({ runId: second.id, coordinatorHandle: 'term_taker', coordinatorPaneKey: PTY_PANE }) + expect(db.getRunRaw(second.id)).toMatchObject({ + coordinator_handle: 'term_taker', + coordinator_actor: null + }) + // Neither Run ever became reachable at the worker's session address. + expect(db.getRunMailboxOwnerIdsForHandle(`session:${WORKER_SESSION_ID}`)).toEqual([]) + }) +}) diff --git a/src/shared/orchestration-actor.test.ts b/src/shared/orchestration-actor.test.ts new file mode 100644 index 000000000000..cdfee1437a8d --- /dev/null +++ b/src/shared/orchestration-actor.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { + formatOrchestrationActor, + normalizeOrchestrationActor, + parseOrchestrationActor, + sessionOrchestrationActor +} from './orchestration-actor' + +const SESSION_ID = '0b7e4c2a-5f1d-4e8a-9c3b-2d6f8a1e4b70' +const ADDRESS = `session:${SESSION_ID}` + +describe('orchestration actor codec', () => { + it('spells a session actor as its mailbox address and parses it back', () => { + const actor = { kind: 'session', id: SESSION_ID } as const + + expect(formatOrchestrationActor(actor)).toBe(ADDRESS) + expect(parseOrchestrationActor(ADDRESS)).toEqual(actor) + expect(formatOrchestrationActor(parseOrchestrationActor(ADDRESS) ?? actor)).toBe(ADDRESS) + }) + + it('normalizes a bare Orca session id and its address to the same actor', () => { + expect(normalizeOrchestrationActor(SESSION_ID)).toEqual({ kind: 'session', id: SESSION_ID }) + expect(normalizeOrchestrationActor(ADDRESS)).toEqual({ kind: 'session', id: SESSION_ID }) + }) + + it('reads only the addressed spelling when parsing a stored value', () => { + // A bare id in a stored column or a recipient slot is not an actor; only input may be bare. + expect(parseOrchestrationActor(SESSION_ID)).toBeNull() + expect(parseOrchestrationActor(null)).toBeNull() + expect(parseOrchestrationActor(undefined)).toBeNull() + expect(parseOrchestrationActor('')).toBeNull() + }) + + it.each([ + ['an unknown kind', `pane:${SESSION_ID}`], + ['the Run mailbox namespace', 'run:run_123'], + ['the Dispatch mailbox namespace', 'dispatch:ctx_123'], + ['an empty kind', `:${SESSION_ID}`], + ['an empty id', 'session:'], + ['an id with a separator', `session:${SESSION_ID}:extra`], + ['an id the session predicate rejects', 'session:short'], + ['a terminal handle', 'term_4f2c9a'] + ])('refuses %s', (_label, value) => { + expect(parseOrchestrationActor(value)).toBeNull() + }) + + it('validates a session id with the session-record predicate', () => { + expect(sessionOrchestrationActor(SESSION_ID)).toEqual({ kind: 'session', id: SESSION_ID }) + expect(sessionOrchestrationActor('has space in it')).toBeNull() + expect(sessionOrchestrationActor('x'.repeat(129))).toBeNull() + expect(normalizeOrchestrationActor('session:has space in it')).toBeNull() + expect(normalizeOrchestrationActor('has space in it')).toBeNull() + }) +}) From 99f55680545022d3179dc07cce03e7b9babc1277 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:59:31 -0700 Subject: [PATCH 03/11] refactor(orchestration): fill structured-worker actors from one open-time call site --- .../orchestration/db/schema/migrate-v42.ts | 5 ++-- .../structured-worker-actor-backfill.ts | 6 ++-- ...chestration-actor-column-migration.test.ts | 29 +++++++++++++++++++ 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/orchestration/db/schema/migrate-v42.ts b/src/main/runtime/orchestration/db/schema/migrate-v42.ts index 5ea27823ff70..3ac80de48172 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v42.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v42.ts @@ -1,5 +1,4 @@ import type { OrchestrationDb } from '../orchestration-db' -import { backfillStructuredWorkerActors } from './structured-worker-actor-backfill' const ACTOR_COLUMNS = [ ['runs', 'coordinator_actor'], @@ -10,7 +9,8 @@ const ACTOR_COLUMNS = [ /** * Orchestration actor columns (`session:`, see orchestration-actor): who a Run's coordinator * and a Dispatch's assignee and creator are when that party is a structured session. PTY rows keep - * NULL and keep their handle and pane-key identity. + * NULL and keep their handle and pane-key identity. Existing structured-worker rows get their actor + * from `backfillStructuredWorkerActors`, which runs after migrate on every open. * * Dev databases stamped v42 by an earlier prototype hold `*_principal` columns instead. They are * unsupported: the version-skew probe finds the actor columns missing and replays the chain, which @@ -54,5 +54,4 @@ export function migrateV42(this: OrchestrationDb, current: number): void { VALUES (NEW.id, COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)); END; `) - backfillStructuredWorkerActors(this.db) } diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts index ed49511d5845..415722778202 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts @@ -30,9 +30,9 @@ const RECORDED_WORKER_SESSIONS_SQL = ` * Both markers are minted only for a local, non-WSL session (`structuredWorkerHostScope`), so the * rows carrying them were written by this host. * - * Runs in the v42 migration and on every open, because a binary rolled back past v42 keeps writing - * structured-worker rows without an actor after user_version is already 42. It fills NULLs only, - * so an actor a writer recorded is never rewritten. + * Runs after migrate on every open, not only once at v42: a binary rolled back past v42 keeps + * writing structured-worker rows without an actor after user_version is already 42. It fills NULLs + * only, so an actor a writer recorded is never rewritten. */ export function backfillStructuredWorkerActors(db: Database.Database): void { let recordedSessions: Map> | undefined diff --git a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts index 538de2761489..a05356616fae 100644 --- a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts @@ -249,6 +249,35 @@ describe('orchestration actor column migration', () => { } }) + it('upgrades a database from before the coordinator cache through the static triggers', () => { + const path = tempDbPath() + const seed = new OrchestrationDb(path) + const rows = seedStructuredAndPtyRows(seed) + seed.close() + stripActorSchema(path, 27) + const raw = new Database(path) + raw.exec(` + DROP TRIGGER trg_runs_remember_coordinator_insert; + DROP TRIGGER trg_runs_remember_coordinator_update; + DROP TABLE run_coordinator_handles; + `) + raw.close() + + // createTables installs its static triggers before this chain's v40 step inserts into runs, so + // a static form naming coordinator_actor would fail to prepare here. + const db = new OrchestrationDb(path) + try { + expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + for (const sql of coordinatorTriggerSql(db.db)) { + expect(sql).toContain('COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)') + } + expect(db.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) + expect(coordinatorAddresses(db.db, [rows.ptyRunId])).toEqual([`${rows.ptyRunId} term_coord`]) + } finally { + db.close() + } + }) + it('lets a v41 binary read and write a v42 database with actors in it', () => { const path = tempDbPath() const seed = new OrchestrationDb(path) From d984e7ac8d00280b7c800ebb5f8bdaf5f4025700 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:00:23 -0700 Subject: [PATCH 04/11] fix(orchestration): refuse terminal handles as session actors and clear the assignee actor on reassignment --- .../worker-dispatch-assignee-actor.test.ts | 76 +++++++++++++++++++ .../worker-dispatch-authority.ts | 2 +- .../failed-start-dispatch-identity.ts | 3 +- src/shared/orchestration-actor.test.ts | 11 +++ src/shared/orchestration-actor.ts | 15 +++- 5 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-actor.test.ts diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-actor.test.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-actor.test.ts new file mode 100644 index 000000000000..199f23569a51 --- /dev/null +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-actor.test.ts @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { sessionOrchestrationActor } from '../../../../../shared/orchestration-actor' +import { mintStructuredWorkerHandle } from '../../../structured-worker-identity' +import { OrchestrationDb } from '../orchestration-db' + +const EARLIER_ACTOR = 'session:7d9f1b3e-5a2c-4e6b-8f0a-1c3e5a7b9d42' +const WORKER_PANE = 'tab_worker:88888888-8888-4888-8888-888888888888' + +describe('assignee identity writers', () => { + let db: OrchestrationDb | undefined + + afterEach(() => { + db?.close() + db = undefined + }) + + /** A starting Dispatch whose row already names an actor, standing in for any earlier writer. */ + function startingDispatchWithActor(target: OrchestrationDb): string { + const task = target.createTask({ runId: 'run_legacy_local', spec: 'worker' }) + const started = target.createStartingWorkerDispatch({ + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER, + taskId: task.id, + startOptions: {} + }) + target.db + .prepare('UPDATE dispatch_contexts SET assignee_actor = ? WHERE id = ?') + .run(EARLIER_ACTOR, started.dispatch.id) + return started.dispatch.id + } + + it('clears the actor when worker authority names the assignee', () => { + db = new OrchestrationDb(':memory:') + const dispatchId = startingDispatchWithActor(db) + + db.prepareStartingWorkerAuthority({ + dispatchId, + handle: 'term_worker', + paneKey: WORKER_PANE, + processIncarnation: 'pty_proc_9c1d:31', + worktreeId: 'wt_1', + setupState: 'not_applicable', + effects: [] + }) + + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + assignee_handle: 'term_worker', + assignee_actor: null + }) + }) + + it('clears the actor when a failed start records the terminal it owned', () => { + db = new OrchestrationDb(':memory:') + const dispatchId = startingDispatchWithActor(db) + db.recordCreatedWorkerTerminalCustody({ + dispatchId, + handle: 'term_worker', + paneKey: WORKER_PANE, + processIncarnation: 'pty_proc_9c1d:31', + worktreeId: 'wt_1' + }) + db.recordWorkerStage({ dispatchId, stage: 'agent_readiness', terminalHandle: 'term_worker' }) + + db.failWorkerStart(dispatchId, 'agent_readiness', 'agent never became ready') + + expect(db.getDispatchContextById(dispatchId)).toMatchObject({ + assignee_handle: 'term_worker', + assignee_actor: null + }) + }) + + it('refuses a minted structured-worker handle as a session id', () => { + // Ties the codec's handle-prefix refusal to the handle this runtime actually mints. + expect(sessionOrchestrationActor(mintStructuredWorkerHandle())).toBeNull() + }) +}) 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 b33a8798dc3e..a3a45417a8bd 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 @@ -54,7 +54,7 @@ export function prepareStartingWorkerAuthority( .prepare( `UPDATE dispatch_contexts SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, - host_scope = ?, + assignee_actor = NULL, host_scope = ?, capability_hash = ?, launch_token_hash = COALESCE(launch_token_hash, ?), capability_revoked_at = NULL, consumer_generation = consumer_generation + 1 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 671b12c39a71..3e6e533be82b 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 @@ -21,7 +21,8 @@ export function recordFailedStartDispatchIdentity( db.db .prepare( `UPDATE dispatch_contexts - SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, host_scope = ? + SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, host_scope = ?, + assignee_actor = NULL WHERE id = ? AND status = 'failed' AND capability_hash IS NULL` ) .run( diff --git a/src/shared/orchestration-actor.test.ts b/src/shared/orchestration-actor.test.ts index cdfee1437a8d..e0d242d4e8bf 100644 --- a/src/shared/orchestration-actor.test.ts +++ b/src/shared/orchestration-actor.test.ts @@ -44,6 +44,17 @@ describe('orchestration actor codec', () => { expect(parseOrchestrationActor(value)).toBeNull() }) + it.each([ + ['a PTY terminal handle', 'term_4f2c9a1b-7d3e-4a5f-8b6c-9d0e1f2a3b4c'], + ['a short PTY terminal handle', 'term_4f2c9a'], + ['a structured-worker handle', 'structworker_4f2c9a1b-7d3e-4a5f-8b6c-9d0e1f2a3b4c'] + ])('never turns %s into a session actor', (_label, handle) => { + // Handles share the session-id charset, so the predicate alone would accept them. + expect(normalizeOrchestrationActor(handle)).toBeNull() + expect(sessionOrchestrationActor(handle)).toBeNull() + expect(parseOrchestrationActor(`session:${handle}`)).toBeNull() + }) + it('validates a session id with the session-record predicate', () => { expect(sessionOrchestrationActor(SESSION_ID)).toEqual({ kind: 'session', id: SESSION_ID }) expect(sessionOrchestrationActor('has space in it')).toBeNull() diff --git a/src/shared/orchestration-actor.ts b/src/shared/orchestration-actor.ts index c2a499191c00..b0126437439e 100644 --- a/src/shared/orchestration-actor.ts +++ b/src/shared/orchestration-actor.ts @@ -10,8 +10,17 @@ import { isAgentSessionId } from './agent-session-record' * session record when needed. PTY agents have no actor: a pane outlives the agent in it, so a * pane-keyed actor would be inherited by the pane's next occupant. */ +// 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 to the codec by mistake +// must not become a durable session actor. +const TERMINAL_HANDLE_PREFIXES = ['term_', 'structworker_'] as const + +function isOrchestrationSessionId(id: string): boolean { + return isAgentSessionId(id) && !TERMINAL_HANDLE_PREFIXES.some((prefix) => id.startsWith(prefix)) +} + const ACTOR_ID_PREDICATES = { - session: isAgentSessionId + session: isOrchestrationSessionId } as const satisfies Record boolean> export type OrchestrationActorKind = keyof typeof ACTOR_ID_PREDICATES @@ -40,12 +49,12 @@ export function parseOrchestrationActor( } export function sessionOrchestrationActor(sessionId: string): OrchestrationActor | null { - return isAgentSessionId(sessionId) ? { kind: 'session', id: sessionId } : null + return isOrchestrationSessionId(sessionId) ? { kind: 'session', id: sessionId } : null } /** * For input already known to name a session: its address, or its bare Orca session id. Not for a - * recipient slot, where a bare string is a terminal handle. + * recipient slot, where a bare string names a terminal; handle-shaped ids are refused regardless. */ export function normalizeOrchestrationActor(value: string): OrchestrationActor | null { return parseOrchestrationActor(value) ?? sessionOrchestrationActor(value) From 22d23bf378521915e787b9c3494c717964af3188 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:55:52 -0700 Subject: [PATCH 05/11] test(orchestration): read the current schema version from its constant in the delivery downgrade contract The contract asserted user_version 41 after old code reopens a database current code wrote, so the v42 bump failed it. Assert SCHEMA_VERSION so the next bump cannot strand it; the pre-v41 pin and its v40 stamp stay. --- .../orchestration-delivery-downgrade.unit.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts index e53b3bc9e6cf..8126d33d6010 100644 --- a/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts +++ b/tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts @@ -3,12 +3,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { expect, test } from 'vitest' import { OrchestrationDb } from '../../../src/main/runtime/orchestration/db' +import { SCHEMA_VERSION } from '../../../src/main/runtime/orchestration/db/contract-constants' import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' // Pin the last pre-v41 implementation: this contract specifically exercises status-only readers. const PRE_V41 = 'aac38d698ff75ac4c8658addab48ef5a83617619' -test('pre-v41 code opens, acknowledges and writes a v41 database, then current code reopens it', async () => { +test('pre-v41 code opens, acknowledges and writes a current-schema database, then current code reopens it', async () => { const checkout = await materializeReleaseCheckout(PRE_V41) const baseline = await importReleaseCheckoutModule( checkout, @@ -47,7 +48,8 @@ test('pre-v41 code opens, acknowledges and writes a v41 database, then current c db.close() db = new OldDb(path) - expect(db.db.pragma('user_version', { simple: true })).toBe(41) + // Old code leaves a newer stamp alone, so the reopen still reads the current schema version. + expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) // Old readers retain their original replay semantics, but can acknowledge either stored batch. db.acknowledgeRunDelivery({ ...params, deliveryId: oldBatch.delivery.id }) expect(db.getOrCreateRunDelivery(params)?.delivery.id).toBe(currentBatch.delivery.id) From 19218ab3860b0ab8de045dc23f66e18ef939e79c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:55:39 -0700 Subject: [PATCH 06/11] fix(orchestration): count a Run's coordinator actor only at the generation it was written at A binary without the actor column rebinds and unbinds a Run by rewriting its handle and pane, which it cannot clear the actor beside. A rebind followed by an unbind leaves a row identical to a live chat binding. Both writes bump consumer_generation, which every binary already maintains, so the actor now carries the generation it was written at (coordinator_actor_generation, set in the same statement) and counts only while the two are equal. The coordinator cache, its triggers and the open-time fill read the actor through one rule in run-coordinator-actor; the fill also replaces an actor an older generation left behind. Still schema v42 (unreleased): the column joins migrate-v42 and the v42 skew-probe entries, so a database stamped v42 without it replays the chain. --- .../orchestration/db/row-column-lists.ts | 1 + .../orchestration/db/runs/run-binding.ts | 1 + .../db/runs/run-coordinator-actor.ts | 21 ++++ .../db/runs/run-coordinator-mail-routing.ts | 9 +- .../orchestration/db/runs/run-lookup.ts | 1 + .../db/schema/create-core-tables-sql.ts | 3 + .../orchestration/db/schema/migrate-v42.ts | 38 +++--- .../structured-worker-actor-backfill.test.ts | 31 +++++ .../structured-worker-actor-backfill.ts | 13 +- ...chestration-actor-column-migration.test.ts | 113 +++++++++++++++++- .../orchestration-schema-version-skew.ts | 1 + .../run-coordinator-actor-address.test.ts | 10 +- src/main/runtime/orchestration/types.ts | 2 + .../orchestration/runs/run-receipt.test.ts | 10 +- .../methods/orchestration/runs/run-receipt.ts | 9 +- 15 files changed, 230 insertions(+), 33 deletions(-) create mode 100644 src/main/runtime/orchestration/db/runs/run-coordinator-actor.ts diff --git a/src/main/runtime/orchestration/db/row-column-lists.ts b/src/main/runtime/orchestration/db/row-column-lists.ts index a3480a0d79ba..f03f7eb25356 100644 --- a/src/main/runtime/orchestration/db/row-column-lists.ts +++ b/src/main/runtime/orchestration/db/row-column-lists.ts @@ -14,6 +14,7 @@ export const RUN_COLUMNS = [ 'coordinator_handle', 'coordinator_pane_key', 'coordinator_actor', + 'coordinator_actor_generation', 'consumer_generation', 'legacy', 'created_at', diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index 406b28ee7dfa..c6dff309dfc7 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -138,6 +138,7 @@ export function bindRun( .prepare( `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_actor = NULL, + coordinator_actor_generation = NULL, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-actor.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-actor.ts new file mode 100644 index 000000000000..152fbb3fc39d --- /dev/null +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-actor.ts @@ -0,0 +1,21 @@ +import type { RunRow } from '../../types' + +type RunCoordinatorActorFields = Pick< + RunRow, + 'coordinator_actor' | 'coordinator_actor_generation' | 'consumer_generation' +> + +/** + * A Run's coordinator actor counts only at the `consumer_generation` it was written at. Every write + * that rebinds or unbinds a Run bumps that generation, including one from a binary that predates + * the actor column, so an actor such a write leaves behind stops counting with nothing to clear it. + */ +export function currentRunCoordinatorActor(run: RunCoordinatorActorFields): string | null { + return run.coordinator_actor_generation === run.consumer_generation ? run.coordinator_actor : null +} + +/** The same rule in SQL, for a `runs` row named `row` (a table name, alias, or `NEW`). */ +export function currentRunCoordinatorActorSql(row: string): string { + return `(CASE WHEN ${row}.coordinator_actor_generation = ${row}.consumer_generation + THEN ${row}.coordinator_actor END)` +} 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 fe9e429b08e2..bfd2c1646821 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,4 +1,5 @@ import type { OrchestrationDb } from '../orchestration-db' +import { currentRunCoordinatorActorSql } from './run-coordinator-actor' export function rememberRunCoordinatorHandle( this: OrchestrationDb, @@ -12,12 +13,14 @@ export function rememberRunCoordinatorHandle( .run(runId, terminalHandle) } -// A handle-less structured-session coordinator is remembered by its actor address (migrate-v42). +const CURRENT_COORDINATOR_ADDRESS_SQL = `COALESCE(runs.coordinator_handle, ${currentRunCoordinatorActorSql('runs')})` + +// A handle-less structured-session coordinator is remembered by its current actor (migrate-v42). export function rememberCurrentRunCoordinatorHandles(this: OrchestrationDb): void { this.db.exec(` INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - SELECT id, COALESCE(coordinator_handle, coordinator_actor) FROM runs - WHERE legacy = 0 AND COALESCE(coordinator_handle, coordinator_actor) IS NOT NULL + SELECT id, ${CURRENT_COORDINATOR_ADDRESS_SQL} FROM runs + WHERE legacy = 0 AND ${CURRENT_COORDINATOR_ADDRESS_SQL} IS NOT NULL `) } diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index 3c6ce99bc411..0cd229b2b7ca 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -136,6 +136,7 @@ export function unbindOtherRunsForPane( .prepare( `UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL, coordinator_actor = NULL, + coordinator_actor_generation = NULL, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` diff --git a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts index bf6e5a6ad8f3..788a4506a9bc 100644 --- a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts @@ -9,6 +9,9 @@ CREATE TABLE IF NOT EXISTS runs ( coordinator_handle TEXT, coordinator_pane_key TEXT, coordinator_actor TEXT, + -- The consumer_generation coordinator_actor was written at; the actor counts only while they are + -- equal (run-coordinator-actor). So bump consumer_generation for a rebind or unbind and nothing else. + coordinator_actor_generation INTEGER, consumer_generation INTEGER NOT NULL DEFAULT 0, legacy INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/src/main/runtime/orchestration/db/schema/migrate-v42.ts b/src/main/runtime/orchestration/db/schema/migrate-v42.ts index 3ac80de48172..8e7a1a8b0c04 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v42.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v42.ts @@ -1,29 +1,35 @@ import type { OrchestrationDb } from '../orchestration-db' +import { currentRunCoordinatorActorSql } from '../runs/run-coordinator-actor' const ACTOR_COLUMNS = [ - ['runs', 'coordinator_actor'], - ['dispatch_contexts', 'assignee_actor'], - ['dispatch_contexts', 'creator_actor'] + ['runs', 'coordinator_actor', 'TEXT'], + ['runs', 'coordinator_actor_generation', 'INTEGER'], + ['dispatch_contexts', 'assignee_actor', 'TEXT'], + ['dispatch_contexts', 'creator_actor', 'TEXT'] ] as const +const NEW_COORDINATOR_ADDRESS_SQL = `COALESCE(NEW.coordinator_handle, ${currentRunCoordinatorActorSql('NEW')})` + /** * Orchestration actor columns (`session:`, see orchestration-actor): who a Run's coordinator * and a Dispatch's assignee and creator are when that party is a structured session. PTY rows keep * NULL and keep their handle and pane-key identity. Existing structured-worker rows get their actor - * from `backfillStructuredWorkerActors`, which runs after migrate on every open. + * from `backfillStructuredWorkerActors`, which runs after migrate on every open. A coordinator actor + * carries the consumer generation it was written at and counts only at that generation. * - * Dev databases stamped v42 by an earlier prototype hold `*_principal` columns instead. They are - * unsupported: the version-skew probe finds the actor columns missing and replays the chain, which - * adds these columns and leaves the stale ones unread. + * Dev databases stamped v42 by an earlier prototype hold `*_principal` columns instead, and ones + * stamped by an earlier build of this step lack `coordinator_actor_generation`. They are + * unsupported: the version-skew probe finds a column missing and replays the chain, which adds these + * columns and leaves the stale ones unread. An actor with no generation never counts. */ export function migrateV42(this: OrchestrationDb, current: number): void { if (current >= 42) { return } // Guarded because createTables runs first on every open and already gives a fresh database these. - for (const [table, column] of ACTOR_COLUMNS) { + for (const [table, column, type] of ACTOR_COLUMNS) { if (!this.hasColumn(table, column)) { - this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} TEXT`) + this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`) } } this.db.exec(` @@ -32,8 +38,8 @@ export function migrateV42(this: OrchestrationDb, current: number): void { CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_actor ON dispatch_contexts(assignee_actor) WHERE assignee_actor IS NOT NULL; `) - // A handle-less coordinator is remembered by its actor, which is already a mailbox address, so - // every reader of this cache matches it unchanged. This step owns the trigger form: the static + // A handle-less coordinator is remembered by its current actor, which is already a mailbox + // address, so every reader of this cache matches it unchanged. This step owns the trigger form: the static // createTables SQL must stay handle-only (see create-core-tables-sql), and CREATE TRIGGER IF NOT // EXISTS never replaces an existing database's triggers, so they are dropped and recreated by name. this.db.exec(` @@ -41,17 +47,17 @@ export function migrateV42(this: OrchestrationDb, current: number): void { DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_update; CREATE TRIGGER trg_runs_remember_coordinator_insert AFTER INSERT ON runs - WHEN NEW.legacy = 0 AND COALESCE(NEW.coordinator_handle, NEW.coordinator_actor) IS NOT NULL + WHEN NEW.legacy = 0 AND ${NEW_COORDINATOR_ADDRESS_SQL} IS NOT NULL BEGIN INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - VALUES (NEW.id, COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)); + VALUES (NEW.id, ${NEW_COORDINATOR_ADDRESS_SQL}); END; CREATE TRIGGER trg_runs_remember_coordinator_update - AFTER UPDATE OF coordinator_handle, coordinator_actor ON runs - WHEN NEW.legacy = 0 AND COALESCE(NEW.coordinator_handle, NEW.coordinator_actor) IS NOT NULL + AFTER UPDATE OF coordinator_handle, coordinator_actor, coordinator_actor_generation ON runs + WHEN NEW.legacy = 0 AND ${NEW_COORDINATOR_ADDRESS_SQL} IS NOT NULL BEGIN INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - VALUES (NEW.id, COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)); + VALUES (NEW.id, ${NEW_COORDINATOR_ADDRESS_SQL}); END; `) } diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts index fbacd3675ffa..b0c3ceea6e19 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts @@ -117,4 +117,35 @@ describe('structured worker actor backfill', () => { expect(actors(id).assignee).toBe(`session:${SESSION_C}`) }) + + it("fills a worker-coordinated Run over an actor an older binding's generation left behind", () => { + db = new OrchestrationDb(':memory:') + const runFor = (sessionId: string): string => { + const handle = mintStructuredWorkerHandle() + const paneKey = mintStructuredWorkerPaneKey(sessionId) + dispatch({ handle, paneKey, incarnation: structuredWorkerProcessIncarnation(sessionId) }) + return db.createRun({ + objective: sessionId, + coordinatorHandle: handle, + coordinatorPaneKey: paneKey + }).id + } + const stale = runFor(SESSION_A) + const recorded = runFor(SESSION_B) + const setActor = db.db.prepare( + `UPDATE runs SET coordinator_actor = ?, coordinator_actor_generation = consumer_generation - ? + WHERE id = ?` + ) + // An older binary rebound this Run to the worker over session C's actor, which it cannot see. + setActor.run(`session:${SESSION_C}`, 1, stale) + // A writer recorded this one at the current generation. + setActor.run(`session:${SESSION_C}`, 0, recorded) + + backfillStructuredWorkerActors(db.db) + + const filled = db.getRunRaw(stale) + expect(filled?.coordinator_actor).toBe(`session:${SESSION_A}`) + expect(filled?.coordinator_actor_generation).toBe(filled?.consumer_generation) + expect(db.getRunRaw(recorded)?.coordinator_actor).toBe(`session:${SESSION_C}`) + }) }) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts index 415722778202..b18effbfec9d 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts @@ -9,6 +9,9 @@ import { isStructuredWorkerHandle, sessionIdFromStructuredWorkerIncarnation } from '../../../structured-worker-identity' +import { currentRunCoordinatorActorSql } from '../runs/run-coordinator-actor' + +const CURRENT_COORDINATOR_ACTOR_SQL = currentRunCoordinatorActorSql('runs') // GLOB is a case-sensitive prefix filter; the canonical predicates still decide every row. const HANDLE_GLOB = `${STRUCTURED_WORKER_HANDLE_PREFIX}*` @@ -31,8 +34,8 @@ const RECORDED_WORKER_SESSIONS_SQL = ` * rows carrying them were written by this host. * * Runs after migrate on every open, not only once at v42: a binary rolled back past v42 keeps - * writing structured-worker rows without an actor after user_version is already 42. It fills NULLs - * only, so an actor a writer recorded is never rewritten. + * writing structured-worker rows without an actor after user_version is already 42. It fills only + * rows with no actor that counts, so an actor a writer recorded is never rewritten. */ export function backfillStructuredWorkerActors(db: Database.Database): void { let recordedSessions: Map> | undefined @@ -92,14 +95,16 @@ export function backfillStructuredWorkerActors(db: Database.Database): void { } } + // A coordinator actor left at an older generation counts as none, so the handle's session fills it. const coordinators = db .prepare( `SELECT id, coordinator_handle FROM runs - WHERE coordinator_actor IS NULL AND coordinator_handle GLOB ?` + WHERE ${CURRENT_COORDINATOR_ACTOR_SQL} IS NULL AND coordinator_handle GLOB ?` ) .all(HANDLE_GLOB) const setCoordinator = db.prepare( - 'UPDATE runs SET coordinator_actor = ? WHERE id = ? AND coordinator_actor IS NULL' + `UPDATE runs SET coordinator_actor = ?, coordinator_actor_generation = consumer_generation + WHERE id = ? AND ${CURRENT_COORDINATOR_ACTOR_SQL} IS NULL` ) for (const row of coordinators) { const actor = actorFor(row.coordinator_handle, null) diff --git a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts index a05356616fae..8863e1fdf4b2 100644 --- a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts @@ -10,6 +10,10 @@ import { } from '../structured-worker-identity' import { OrchestrationDb } from './db' import { SCHEMA_VERSION } from './db/contract-constants' +import { + currentRunCoordinatorActor, + currentRunCoordinatorActorSql +} from './db/runs/run-coordinator-actor' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' const SESSION_ID = '5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11' @@ -106,6 +110,7 @@ function stripActorSchema(path: string, version: number): void { DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; ALTER TABLE runs DROP COLUMN coordinator_actor; + ALTER TABLE runs DROP COLUMN coordinator_actor_generation; ALTER TABLE dispatch_contexts DROP COLUMN assignee_actor; ALTER TABLE dispatch_contexts DROP COLUMN creator_actor; ${HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL} @@ -204,7 +209,7 @@ describe('orchestration actor column migration', () => { ) // CREATE TRIGGER IF NOT EXISTS alone would have kept the handle-only form here. for (const sql of coordinatorTriggerSql(db.db)) { - expect(sql).toContain('COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)') + expect(sql).toContain('NEW.coordinator_actor_generation = NEW.consumer_generation') } } finally { db.close() @@ -269,7 +274,7 @@ describe('orchestration actor column migration', () => { try { expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) for (const sql of coordinatorTriggerSql(db.db)) { - expect(sql).toContain('COALESCE(NEW.coordinator_handle, NEW.coordinator_actor)') + expect(sql).toContain('NEW.coordinator_actor_generation = NEW.consumer_generation') } expect(db.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) expect(coordinatorAddresses(db.db, [rows.ptyRunId])).toEqual([`${rows.ptyRunId} term_coord`]) @@ -287,8 +292,9 @@ describe('orchestration actor column migration', () => { expect(upgraded.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) upgraded.db .prepare( - `INSERT INTO runs (id, objective, coordinator_actor, consumer_generation, legacy) - VALUES ('run_session', 'session coordinator', ?, 1, 0)` + `INSERT INTO runs ( + id, objective, coordinator_actor, coordinator_actor_generation, consumer_generation, legacy + ) VALUES ('run_session', 'session coordinator', ?, 1, 1, 0)` ) .run(CHAT_SESSION_ACTOR) upgraded.close() @@ -366,6 +372,10 @@ describe('orchestration actor column migration', () => { expect(rolledForward.getRunMailboxOwnerIdsForHandle(CHAT_SESSION_ACTOR)).toEqual([ 'run_session' ]) + // v41's rebind bumped the generation, so the actor it could not clear no longer counts. + const rebound = rolledForward.getRunRaw(rows.structuredRunId) + expect(rebound?.coordinator_actor).toBe(SESSION_ACTOR) + expect(rebound && currentRunCoordinatorActor(rebound)).toBeNull() } finally { rolledForward.close() } @@ -450,4 +460,99 @@ describe('orchestration actor column migration', () => { db.close() } }) + + it('stops counting a chat coordinator actor once a v41 binary rebinds and then unbinds the Run', () => { + const path = tempDbPath() + const seeded = new OrchestrationDb(path) + seeded.db + .prepare( + `INSERT INTO runs ( + id, objective, coordinator_actor, coordinator_actor_generation, consumer_generation, legacy + ) VALUES ('run_chat', 'chat coordinated', ?, 1, 1, 0)` + ) + .run(CHAT_SESSION_ACTOR) + // The cache row this binding wrote; a later open must not be able to write it back. + seeded.db.prepare('DELETE FROM run_coordinator_handles WHERE run_id = ?').run('run_chat') + seeded.close() + + // Each statement below is v41's own SQL. + const v41 = new Database(path) + // A terminal takes the Run over (bindRun)... + v41 + .prepare( + `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run('term_taker', PTY_WORKER_PANE, 'run_chat') + // ...then claims another Run from the same pane, which unbinds this one (unbindOtherRunsForPane). + v41 + .prepare( + `UPDATE runs SET coordinator_handle = NULL, coordinator_pane_key = NULL, + consumer_generation = consumer_generation + 1, updated_at = datetime('now') + WHERE id = ?` + ) + .run('run_chat') + // Without the generation this row is byte-identical to a live chat binding. + expect( + v41 + .prepare( + `SELECT coordinator_handle, coordinator_pane_key, coordinator_actor, consumer_generation + FROM runs WHERE id = ?` + ) + .get('run_chat') + ).toEqual({ + coordinator_handle: null, + coordinator_pane_key: null, + coordinator_actor: CHAT_SESSION_ACTOR, + consumer_generation: 3 + }) + v41.close() + + const reopened = new OrchestrationDb(path) + try { + const run = reopened.getRunRaw('run_chat') + expect(run && currentRunCoordinatorActor(run)).toBeNull() + expect( + reopened.db + .prepare(`SELECT id FROM runs WHERE ${currentRunCoordinatorActorSql('runs')} = ?`) + .all(CHAT_SESSION_ACTOR) + ).toEqual([]) + expect(coordinatorAddresses(reopened.db, ['run_chat'])).toEqual(['run_chat term_taker']) + } finally { + reopened.close() + } + }) + + it('replays a database stamped v42 before the coordinator actor carried its generation', () => { + const path = tempDbPath() + const seed = new OrchestrationDb(path) + const rows = seedStructuredAndPtyRows(seed) + seed.close() + const raw = new Database(path) + raw.exec(` + DROP TRIGGER trg_runs_remember_coordinator_insert; + DROP TRIGGER trg_runs_remember_coordinator_update; + ALTER TABLE runs DROP COLUMN coordinator_actor_generation; + ${HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL} + `) + raw.pragma('user_version = 42') + try { + expect(resolveOrchestrationMigrationStartVersion(raw, 42, SCHEMA_VERSION)).toBe(6) + } finally { + raw.close() + } + + const db = new OrchestrationDb(path) + try { + expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + for (const sql of coordinatorTriggerSql(db.db)) { + expect(sql).toContain('NEW.coordinator_actor_generation = NEW.consumer_generation') + } + const run = db.getRunRaw(rows.structuredRunId) + expect(run && currentRunCoordinatorActor(run)).toBe(SESSION_ACTOR) + } finally { + db.close() + } + }) }) diff --git a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts index f6194202c436..021b95df436d 100644 --- a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts +++ b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts @@ -45,6 +45,7 @@ const VERSIONED_POST_V6_COLUMNS = [ { version: 37, table: 'dispatch_contexts', column: 'creator_pane_key' }, { version: 40, table: 'remote_dispatch_attachments', column: 'home_run_id' }, { version: 42, table: 'runs', column: 'coordinator_actor' }, + { version: 42, table: 'runs', column: 'coordinator_actor_generation' }, { version: 42, table: 'dispatch_contexts', column: 'assignee_actor' }, { version: 42, table: 'dispatch_contexts', column: 'creator_actor' } ] as const diff --git a/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts b/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts index 344eaf9b08aa..da49af144ac2 100644 --- a/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts +++ b/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts @@ -31,8 +31,9 @@ function addressesFor(db: OrchestrationDb, runId: string): string[] { function insertSessionCoordinatedRun(db: OrchestrationDb, runId: string): void { db.db .prepare( - `INSERT INTO runs (id, objective, coordinator_actor, consumer_generation, legacy) - VALUES (?, 'coordinated by a structured session', ?, 1, 0)` + `INSERT INTO runs ( + id, objective, coordinator_actor, coordinator_actor_generation, consumer_generation, legacy + ) VALUES (?, 'coordinated by a structured session', ?, 1, 1, 0)` ) .run(runId, CHAT_ACTOR) } @@ -82,7 +83,10 @@ describe('Run coordinator actor address', () => { ) .run() db.db - .prepare('UPDATE runs SET coordinator_actor = ? WHERE id = ?') + .prepare( + `UPDATE runs SET coordinator_actor = ?, coordinator_actor_generation = consumer_generation + WHERE id = ?` + ) .run(CHAT_ACTOR, 'run_unbound') expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ACTOR]) db.db.prepare('DELETE FROM run_coordinator_handles WHERE run_id = ?').run('run_unbound') diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index 2bbbb6a9ebff..bafb98f07e10 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -48,6 +48,8 @@ export type RunRow = { coordinator_pane_key: string | null /** `session:` for a structured-session coordinator; NULL for a PTY one. See orchestration-actor. */ coordinator_actor: string | null + /** The consumer_generation the actor was written at; see currentRunCoordinatorActor. */ + coordinator_actor_generation: number | null consumer_generation: number legacy: number created_at: string 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 cb68ed484534..f8045859b6b5 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 @@ -10,6 +10,7 @@ const RUN_ROW: RunRow = { coordinator_handle: 'term_coord', coordinator_pane_key: 'tab_coord:11111111-1111-4111-8111-111111111111', coordinator_actor: 'session:22222222-2222-4222-8222-222222222222', + coordinator_actor_generation: 3, consumer_generation: 3, legacy: 0, created_at: '2026-09-04T18:53:07Z', @@ -32,6 +33,7 @@ describe('exposeRun', () => { expect(exposed).not.toHaveProperty('home_database') expect(exposed).not.toHaveProperty('coordinator_pane_key') expect(exposed).not.toHaveProperty('coordinator_actor') + expect(exposed).not.toHaveProperty('coordinator_actor_generation') }) it('preserves every published column by value', () => { @@ -56,9 +58,15 @@ describe('exposeRun', () => { }) it('strips the columns even when they are null', () => { - const exposed = exposeRun({ ...RUN_ROW, coordinator_pane_key: null, coordinator_actor: null }) + const exposed = exposeRun({ + ...RUN_ROW, + coordinator_pane_key: null, + coordinator_actor: null, + coordinator_actor_generation: null + }) expect(exposed).not.toHaveProperty('coordinator_pane_key') expect(exposed).not.toHaveProperty('coordinator_actor') + expect(exposed).not.toHaveProperty('coordinator_actor_generation') }) }) diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts index 35a50c4d8c42..7ce98532e76e 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts @@ -1,8 +1,13 @@ import type { RunRow } from '../../../../orchestration/types' // Why: home_database and coordinator_pane_key are runtime routing state; no caller reads them. -// coordinator_actor stays off the wire until a reader needs it; publishing it is a wire change. -const INTERNAL_RUN_COLUMNS = ['home_database', 'coordinator_pane_key', 'coordinator_actor'] as const +// The coordinator actor stays off the wire until a reader needs it; publishing it is a wire change. +const INTERNAL_RUN_COLUMNS = [ + 'home_database', + 'coordinator_pane_key', + 'coordinator_actor', + 'coordinator_actor_generation' +] as const export type RunReceipt = Omit From bd3a8fe734f58df8a310b024718e6ef691f6ec87 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:47:49 -0700 Subject: [PATCH 07/11] refactor(orchestration): drop the unused coordinator-actor index and bare-id normalizer Nothing in this stack looks a Run up by coordinator_actor: callers load the Run and compare its current actor, so idx_runs_coordinator_actor would ship in every database with no reader. v42 is unreleased, so it leaves the migration rather than needing a later drop. normalizeOrchestrationActor had no caller outside its tests; bare session ids enter through sessionOrchestrationActor, and the handle-refusal cases stay covered there and in parseOrchestrationActor. --- src/main/runtime/orchestration/db/schema/migrate-v42.ts | 2 -- .../orchestration-actor-column-migration.test.ts | 1 - src/shared/orchestration-actor.test.ts | 9 --------- src/shared/orchestration-actor.ts | 8 -------- 4 files changed, 20 deletions(-) diff --git a/src/main/runtime/orchestration/db/schema/migrate-v42.ts b/src/main/runtime/orchestration/db/schema/migrate-v42.ts index 8e7a1a8b0c04..9eac20b34e3d 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v42.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v42.ts @@ -33,8 +33,6 @@ export function migrateV42(this: OrchestrationDb, current: number): void { } } this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_runs_coordinator_actor - ON runs(coordinator_actor) WHERE coordinator_actor IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_actor ON dispatch_contexts(assignee_actor) WHERE assignee_actor IS NOT NULL; `) diff --git a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts index 8863e1fdf4b2..de79d5126aae 100644 --- a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts @@ -105,7 +105,6 @@ function seedStructuredAndPtyRows(db: OrchestrationDb): SeededRows { function stripActorSchema(path: string, version: number): void { const raw = new Database(path) raw.exec(` - DROP INDEX idx_runs_coordinator_actor; DROP INDEX idx_dispatch_assignee_actor; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; diff --git a/src/shared/orchestration-actor.test.ts b/src/shared/orchestration-actor.test.ts index e0d242d4e8bf..fab89f15f639 100644 --- a/src/shared/orchestration-actor.test.ts +++ b/src/shared/orchestration-actor.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest' import { formatOrchestrationActor, - normalizeOrchestrationActor, parseOrchestrationActor, sessionOrchestrationActor } from './orchestration-actor' @@ -18,11 +17,6 @@ describe('orchestration actor codec', () => { expect(formatOrchestrationActor(parseOrchestrationActor(ADDRESS) ?? actor)).toBe(ADDRESS) }) - it('normalizes a bare Orca session id and its address to the same actor', () => { - expect(normalizeOrchestrationActor(SESSION_ID)).toEqual({ kind: 'session', id: SESSION_ID }) - expect(normalizeOrchestrationActor(ADDRESS)).toEqual({ kind: 'session', id: SESSION_ID }) - }) - it('reads only the addressed spelling when parsing a stored value', () => { // A bare id in a stored column or a recipient slot is not an actor; only input may be bare. expect(parseOrchestrationActor(SESSION_ID)).toBeNull() @@ -50,7 +44,6 @@ describe('orchestration actor codec', () => { ['a structured-worker handle', 'structworker_4f2c9a1b-7d3e-4a5f-8b6c-9d0e1f2a3b4c'] ])('never turns %s into a session actor', (_label, handle) => { // Handles share the session-id charset, so the predicate alone would accept them. - expect(normalizeOrchestrationActor(handle)).toBeNull() expect(sessionOrchestrationActor(handle)).toBeNull() expect(parseOrchestrationActor(`session:${handle}`)).toBeNull() }) @@ -59,7 +52,5 @@ describe('orchestration actor codec', () => { expect(sessionOrchestrationActor(SESSION_ID)).toEqual({ kind: 'session', id: SESSION_ID }) expect(sessionOrchestrationActor('has space in it')).toBeNull() expect(sessionOrchestrationActor('x'.repeat(129))).toBeNull() - expect(normalizeOrchestrationActor('session:has space in it')).toBeNull() - expect(normalizeOrchestrationActor('has space in it')).toBeNull() }) }) diff --git a/src/shared/orchestration-actor.ts b/src/shared/orchestration-actor.ts index b0126437439e..75fc0bbe95d6 100644 --- a/src/shared/orchestration-actor.ts +++ b/src/shared/orchestration-actor.ts @@ -51,11 +51,3 @@ export function parseOrchestrationActor( export function sessionOrchestrationActor(sessionId: string): OrchestrationActor | null { return isOrchestrationSessionId(sessionId) ? { kind: 'session', id: sessionId } : null } - -/** - * For input already known to name a session: its address, or its bare Orca session id. Not for a - * recipient slot, where a bare string names a terminal; handle-shaped ids are refused regardless. - */ -export function normalizeOrchestrationActor(value: string): OrchestrationActor | null { - return parseOrchestrationActor(value) ?? sessionOrchestrationActor(value) -} From a38ebdd35ed6b06e3bfff9d0aa1f1c86874aa58d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:56:36 -0700 Subject: [PATCH 08/11] fix(orchestration): keep the coordinator-actor index the caller lookup needs The next step finds a caller's Runs with one statement that ORs a pane-leaf match with `coordinator_actor = ?`. SQLite splits that OR across two indexes only when both sides have one; without idx_runs_coordinator_actor the plan falls back to scanning every Run on each lookup. v42 is unreleased, so the index returns to migrate-v42 rather than needing a later schema step. --- .../orchestration/db/schema/migrate-v42.ts | 3 ++ ...chestration-actor-column-migration.test.ts | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/main/runtime/orchestration/db/schema/migrate-v42.ts b/src/main/runtime/orchestration/db/schema/migrate-v42.ts index 9eac20b34e3d..1b275b29e4e4 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v42.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v42.ts @@ -32,7 +32,10 @@ export function migrateV42(this: OrchestrationDb, current: number): void { this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`) } } + // A Run lookup that ORs a pane-leaf match with an actor match scans every Run without this index. this.db.exec(` + CREATE INDEX IF NOT EXISTS idx_runs_coordinator_actor + ON runs(coordinator_actor) WHERE coordinator_actor IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_actor ON dispatch_contexts(assignee_actor) WHERE assignee_actor IS NOT NULL; `) diff --git a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts index de79d5126aae..6589b49878b9 100644 --- a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts @@ -10,6 +10,7 @@ import { } from '../structured-worker-identity' import { OrchestrationDb } from './db' import { SCHEMA_VERSION } from './db/contract-constants' +import { RUN_PANE_KEY_MATCH_SUFFIX_SQL } from './db/pane-key-match' import { currentRunCoordinatorActor, currentRunCoordinatorActorSql @@ -105,6 +106,7 @@ function seedStructuredAndPtyRows(db: OrchestrationDb): SeededRows { function stripActorSchema(path: string, version: number): void { const raw = new Database(path) raw.exec(` + DROP INDEX idx_runs_coordinator_actor; DROP INDEX idx_dispatch_assignee_actor; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; @@ -118,6 +120,21 @@ function stripActorSchema(path: string, version: number): void { raw.close() } +/** The plan for finding a caller's Runs by pane leaf or by coordinator actor in one statement. */ +function coordinatorLookupPlan(db: Database.Database): string { + return db + .prepare( + `EXPLAIN QUERY PLAN SELECT id FROM runs + WHERE legacy = 0 AND ( + (coordinator_pane_key IS NOT NULL AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ?) + OR coordinator_actor = ? + )` + ) + .all('leaf', CHAT_SESSION_ACTOR) + .map((row) => String(row.detail)) + .join(' | ') +} + function coordinatorTriggerSql(db: Database.Database): string[] { return db .prepare( @@ -523,6 +540,27 @@ describe('orchestration actor column migration', () => { } }) + it('finds Runs by coordinator actor through an index on fresh and upgraded databases', () => { + const expectIndexedLookup = (path: string): void => { + const db = new OrchestrationDb(path) + try { + const plan = coordinatorLookupPlan(db.db) + expect(plan).toContain('USING INDEX idx_runs_coordinator_actor') + expect(plan).not.toContain('SCAN runs') + } finally { + db.close() + } + } + expectIndexedLookup(tempDbPath()) + + const upgradedPath = tempDbPath() + const seed = new OrchestrationDb(upgradedPath) + seedStructuredAndPtyRows(seed) + seed.close() + stripActorSchema(upgradedPath, 41) + expectIndexedLookup(upgradedPath) + }) + it('replays a database stamped v42 before the coordinator actor carried its generation', () => { const path = tempDbPath() const seed = new OrchestrationDb(path) From 246cf56078ff0e092b9716fad549704a1feb221f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:54:38 -0700 Subject: [PATCH 09/11] refactor(orchestration): store the Orca session id instead of an "actor" "Actor" read as a new concept when the columns only ever named a structured session. Rename them to what they hold: coordinator_orca_session_id (with its _generation), assignee_orca_session_id and creator_orca_session_id, plus the matching indexes, still added by migrate-v42 since v42 has not shipped. The columns now store the bare Orca session id rather than session:. The session: mail address is derived from ORCA_SESSION_ADDRESS_PREFIX where mail needs it: the coordinator address triggers and the cache refill share one SQL builder. isOrcaSessionId keeps refusing terminal-handle-shaped ids, and the generation rule and backfill evidence rules are unchanged. A dev database stamped v42 with the earlier *_actor columns replays the chain and gains the new ones. --- .../orchestration/db/contract-constants.ts | 2 +- .../orchestration/db/dispatch-depth.test.ts | 18 +- .../orchestration/db/orchestration-db.ts | 4 +- .../orchestration/db/row-column-lists.ts | 8 +- .../orchestration/db/runs/run-binding.ts | 6 +- .../db/runs/run-coordinator-actor.ts | 21 -- .../db/runs/run-coordinator-mail-routing.ts | 6 +- .../db/runs/run-coordinator-orca-session.ts | 32 +++ .../orchestration/db/runs/run-lookup.ts | 4 +- .../db/schema/create-core-tables-sql.ts | 16 +- .../db/schema/create-graph-tables-sql.ts | 8 +- .../orchestration/db/schema/migrate-v42.ts | 55 ++-- ...ured-worker-orca-session-backfill.test.ts} | 50 ++-- ...tructured-worker-orca-session-backfill.ts} | 66 ++--- ...er-dispatch-assignee-orca-session.test.ts} | 30 +-- .../worker-dispatch-authority.ts | 2 +- .../failed-start-dispatch-identity.ts | 2 +- ...ion-orca-session-column-migration.test.ts} | 246 +++++++++++------- .../orchestration-schema-version-skew.ts | 8 +- ...-coordinator-orca-session-address.test.ts} | 66 ++--- src/main/runtime/orchestration/types.ts | 16 +- .../orchestration/runs/run-receipt.test.ts | 16 +- .../methods/orchestration/runs/run-receipt.ts | 6 +- src/shared/orca-session-address.test.ts | 56 ++++ src/shared/orca-session-address.ts | 34 +++ src/shared/orchestration-actor.test.ts | 56 ---- src/shared/orchestration-actor.ts | 53 ---- 27 files changed, 482 insertions(+), 405 deletions(-) delete mode 100644 src/main/runtime/orchestration/db/runs/run-coordinator-actor.ts create mode 100644 src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts rename src/main/runtime/orchestration/db/schema/{structured-worker-actor-backfill.test.ts => structured-worker-orca-session-backfill.test.ts} (71%) rename src/main/runtime/orchestration/db/schema/{structured-worker-actor-backfill.ts => structured-worker-orca-session-backfill.ts} (56%) rename src/main/runtime/orchestration/db/worker-dispatch/{worker-dispatch-assignee-actor.test.ts => worker-dispatch-assignee-orca-session.test.ts} (60%) rename src/main/runtime/orchestration/{orchestration-actor-column-migration.test.ts => orchestration-orca-session-column-migration.test.ts} (69%) rename src/main/runtime/orchestration/{run-coordinator-actor-address.test.ts => run-coordinator-orca-session-address.test.ts} (64%) create mode 100644 src/shared/orca-session-address.test.ts create mode 100644 src/shared/orca-session-address.ts delete mode 100644 src/shared/orchestration-actor.test.ts delete mode 100644 src/shared/orchestration-actor.ts diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index f065923d110e..f2a8fc12fd1b 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -18,5 +18,5 @@ export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION // Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity, v31 dispatch/resource identity links, v32 bounded worker-terminal recovery metadata, v33 durable mailbox pointer Enter state, v34 role-addressed mailbox deliveries, v35 mailbox delivery default and index-predicate repair, v36 dispatch mailbox consumer generation, v37 recorded dispatch creator identity, v39 structured session journal archives. // v41: derive outstanding deliveries from unread messages. -// v42: structured-session orchestration actor columns. +// v42: structured-session Orca session id columns. export const SCHEMA_VERSION = 42 diff --git a/src/main/runtime/orchestration/db/dispatch-depth.test.ts b/src/main/runtime/orchestration/db/dispatch-depth.test.ts index 408190b0fd7e..28d94e9556b1 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.test.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.test.ts @@ -6,7 +6,7 @@ import { } from '../../structured-worker-identity' import { OrchestrationDb } from '../db' import { AmbiguousDispatchParentError } from './dispatch-depth' -import { backfillStructuredWorkerActors } from './schema/structured-worker-actor-backfill' +import { backfillStructuredWorkerOrcaSessionIds } from './schema/structured-worker-orca-session-backfill' /** * These pin the fence Orca documented but never enforced: before this feature a @@ -290,9 +290,9 @@ describe('nested worker depth', () => { expect(db.resolveCreatorDepth({ kind: 'terminal', handle: 'term_ctx' })).toBe(1) }) - // Pinned for the reader that switches self-dispatch detection to actor equality: equal creator - // and assignee actors must keep meaning bookkeeping, and different ones delegation. - it('records equal actors exactly when a structured session dispatches to itself', () => { + // Pinned for the reader that switches self-dispatch detection to Orca session id equality: equal + // creator and assignee ids must keep meaning bookkeeping, and different ones delegation. + it('records equal Orca session ids exactly when a structured session dispatches to itself', () => { db = new OrchestrationDb(':memory:') const sessionId = '5c7e9a1d-3f6b-4c8e-8d2a-4b6c8e0a2d36' const self = { @@ -315,15 +315,15 @@ describe('nested worker depth', () => { creator: self, maxDepth: UNCAPPED }) - backfillStructuredWorkerActors(db.db) + backfillStructuredWorkerOrcaSessionIds(db.db) const ownRow = db.getDispatchContextById(own.id) - expect(ownRow?.creator_actor).toBe(`session:${sessionId}`) - expect(ownRow?.assignee_actor).toBe(ownRow?.creator_actor) + expect(ownRow?.creator_orca_session_id).toBe(sessionId) + expect(ownRow?.assignee_orca_session_id).toBe(ownRow?.creator_orca_session_id) expect(db.resolveCreatorDepth(self)).toBe(0) const delegatedRow = db.getDispatchContextById(delegated.id) - expect(delegatedRow?.creator_actor).toBe(`session:${sessionId}`) - expect(delegatedRow?.assignee_actor).toBeNull() + expect(delegatedRow?.creator_orca_session_id).toBe(sessionId) + expect(delegatedRow?.assignee_orca_session_id).toBeNull() expect( db.resolveCreatorDepth({ kind: 'terminal', diff --git a/src/main/runtime/orchestration/db/orchestration-db.ts b/src/main/runtime/orchestration/db/orchestration-db.ts index 951b6f8b9ba6..7bd0d65517f4 100644 --- a/src/main/runtime/orchestration/db/orchestration-db.ts +++ b/src/main/runtime/orchestration/db/orchestration-db.ts @@ -9,7 +9,7 @@ import { } from './runs/run-coordinator-mail-routing' import { createTables } from './schema/create-tables' import { migrate } from './schema/migrate' -import { backfillStructuredWorkerActors } from './schema/structured-worker-actor-backfill' +import { backfillStructuredWorkerOrcaSessionIds } from './schema/structured-worker-orca-session-backfill' class OrchestrationDbCore { db: Database.Database @@ -31,7 +31,7 @@ class OrchestrationDbCore { createTables.call(this as unknown as OrchestrationDb) migrate.call(this as unknown as OrchestrationDb) backfillFederatedStubHomeRuns(this.db) - backfillStructuredWorkerActors(this.db) + backfillStructuredWorkerOrcaSessionIds(this.db) createCoordinatorMailRoutingTrigger.call(this as unknown as OrchestrationDb) rememberCurrentRunCoordinatorHandles.call(this as unknown as OrchestrationDb) hardenOrchestrationDatabaseFiles(dbPath) diff --git a/src/main/runtime/orchestration/db/row-column-lists.ts b/src/main/runtime/orchestration/db/row-column-lists.ts index f03f7eb25356..4ee3740c3834 100644 --- a/src/main/runtime/orchestration/db/row-column-lists.ts +++ b/src/main/runtime/orchestration/db/row-column-lists.ts @@ -13,8 +13,8 @@ export const RUN_COLUMNS = [ 'home_database', 'coordinator_handle', 'coordinator_pane_key', - 'coordinator_actor', - 'coordinator_actor_generation', + 'coordinator_orca_session_id', + 'coordinator_orca_session_id_generation', 'consumer_generation', 'legacy', 'created_at', @@ -47,7 +47,7 @@ export const DISPATCH_CONTEXT_COLUMNS = [ 'launch_token_hash', 'assignee_handle', 'assignee_pane_key', - 'assignee_actor', + 'assignee_orca_session_id', 'capability_hash', 'process_incarnation', 'capability_revoked_at', @@ -55,7 +55,7 @@ export const DISPATCH_CONTEXT_COLUMNS = [ 'creator_dispatch_id', 'creator_handle', 'creator_pane_key', - 'creator_actor', + 'creator_orca_session_id', 'host_scope', 'status', 'failure_count', diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index c6dff309dfc7..c5e6f34abfad 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -133,12 +133,12 @@ export function bindRun( this.setLegacyCompatibilityPrincipalStatus(coordinatorPrincipal.id, 'revoked') } } - // The actor belongs to the coordinator being replaced; nothing here resolves the new one's. + // 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_actor = NULL, - coordinator_actor_generation = NULL, + SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_orca_session_id = NULL, + coordinator_orca_session_id_generation = NULL, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` diff --git a/src/main/runtime/orchestration/db/runs/run-coordinator-actor.ts b/src/main/runtime/orchestration/db/runs/run-coordinator-actor.ts deleted file mode 100644 index 152fbb3fc39d..000000000000 --- a/src/main/runtime/orchestration/db/runs/run-coordinator-actor.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { RunRow } from '../../types' - -type RunCoordinatorActorFields = Pick< - RunRow, - 'coordinator_actor' | 'coordinator_actor_generation' | 'consumer_generation' -> - -/** - * A Run's coordinator actor counts only at the `consumer_generation` it was written at. Every write - * that rebinds or unbinds a Run bumps that generation, including one from a binary that predates - * the actor column, so an actor such a write leaves behind stops counting with nothing to clear it. - */ -export function currentRunCoordinatorActor(run: RunCoordinatorActorFields): string | null { - return run.coordinator_actor_generation === run.consumer_generation ? run.coordinator_actor : null -} - -/** The same rule in SQL, for a `runs` row named `row` (a table name, alias, or `NEW`). */ -export function currentRunCoordinatorActorSql(row: string): string { - return `(CASE WHEN ${row}.coordinator_actor_generation = ${row}.consumer_generation - THEN ${row}.coordinator_actor END)` -} 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 bfd2c1646821..76821e989dae 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,5 @@ import type { OrchestrationDb } from '../orchestration-db' -import { currentRunCoordinatorActorSql } from './run-coordinator-actor' +import { currentRunCoordinatorAddressSql } from './run-coordinator-orca-session' export function rememberRunCoordinatorHandle( this: OrchestrationDb, @@ -13,9 +13,9 @@ export function rememberRunCoordinatorHandle( .run(runId, terminalHandle) } -const CURRENT_COORDINATOR_ADDRESS_SQL = `COALESCE(runs.coordinator_handle, ${currentRunCoordinatorActorSql('runs')})` +const CURRENT_COORDINATOR_ADDRESS_SQL = currentRunCoordinatorAddressSql('runs') -// A handle-less structured-session coordinator is remembered by its current actor (migrate-v42). +// A handle-less structured-session coordinator is remembered by its session address (migrate-v42). export function rememberCurrentRunCoordinatorHandles(this: OrchestrationDb): void { this.db.exec(` INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) 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 new file mode 100644 index 000000000000..40146f63fd96 --- /dev/null +++ b/src/main/runtime/orchestration/db/runs/run-coordinator-orca-session.ts @@ -0,0 +1,32 @@ +import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../../shared/orca-session-address' +import type { RunRow } from '../../types' + +type RunCoordinatorOrcaSessionFields = Pick< + RunRow, + 'coordinator_orca_session_id' | 'coordinator_orca_session_id_generation' | 'consumer_generation' +> + +/** + * A Run coordinator's Orca session id counts only at the `consumer_generation` it was written at. + * Every write that rebinds or unbinds a Run bumps that generation, including one from a binary that + * predates the column, so an id such a write leaves behind stops counting with nothing to clear it. + */ +export function currentRunCoordinatorOrcaSessionId( + run: RunCoordinatorOrcaSessionFields +): string | null { + return run.coordinator_orca_session_id_generation === run.consumer_generation + ? run.coordinator_orca_session_id + : null +} + +/** The same rule in SQL, for a `runs` row named `row` (a table name, alias, or `NEW`). */ +export function currentRunCoordinatorOrcaSessionIdSql(row: string): string { + return `(CASE WHEN ${row}.coordinator_orca_session_id_generation = ${row}.consumer_generation + THEN ${row}.coordinator_orca_session_id END)` +} + +/** The mailbox address the coordinator is remembered by: its handle, else its session address. */ +export function currentRunCoordinatorAddressSql(row: string): string { + return `COALESCE(${row}.coordinator_handle, + '${ORCA_SESSION_ADDRESS_PREFIX}' || ${currentRunCoordinatorOrcaSessionIdSql(row)})` +} diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index 0cd229b2b7ca..0a60b4e7f9d3 100644 --- a/src/main/runtime/orchestration/db/runs/run-lookup.ts +++ b/src/main/runtime/orchestration/db/runs/run-lookup.ts @@ -135,8 +135,8 @@ export function unbindOtherRunsForPane( this.db .prepare( `UPDATE runs - SET coordinator_handle = NULL, coordinator_pane_key = NULL, coordinator_actor = NULL, - coordinator_actor_generation = NULL, + SET coordinator_handle = NULL, coordinator_pane_key = NULL, coordinator_orca_session_id = NULL, + coordinator_orca_session_id_generation = NULL, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` diff --git a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts index 788a4506a9bc..b6400d98609e 100644 --- a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts @@ -8,10 +8,11 @@ CREATE TABLE IF NOT EXISTS runs ( home_database TEXT NOT NULL DEFAULT 'this_database', coordinator_handle TEXT, coordinator_pane_key TEXT, - coordinator_actor TEXT, - -- The consumer_generation coordinator_actor was written at; the actor counts only while they are - -- equal (run-coordinator-actor). So bump consumer_generation for a rebind or unbind and nothing else. - coordinator_actor_generation INTEGER, + -- Bare Orca session id the coordinator is addressed by (for a /clear'd chat, its lineage root's). + coordinator_orca_session_id TEXT, + -- The consumer_generation coordinator_orca_session_id was written at; the id counts only while they + -- are equal (run-coordinator-orca-session). So bump consumer_generation for a rebind or unbind only. + coordinator_orca_session_id_generation INTEGER, consumer_generation INTEGER NOT NULL DEFAULT 0, legacy INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -59,9 +60,10 @@ CREATE TABLE IF NOT EXISTS run_coordinator_handles ( CREATE INDEX IF NOT EXISTS idx_run_coordinator_handles_handle ON run_coordinator_handles(terminal_handle, run_id); --- Handle-only on purpose; migrate-v42 replaces both triggers with the actor-aware form. This SQL --- runs before migrate on every open, so it must compile against a pre-v42 runs table: a trigger --- naming coordinator_actor there makes the next INSERT INTO runs fail to prepare mid-migration. +-- Handle-only on purpose; migrate-v42 replaces both triggers with a form that also remembers a +-- handle-less coordinator by its session address. This SQL runs before migrate on every open, so it +-- must compile against a pre-v42 runs table: a trigger naming coordinator_orca_session_id there makes +-- the next INSERT INTO runs fail to prepare mid-migration. CREATE TRIGGER IF NOT EXISTS trg_runs_remember_coordinator_insert AFTER INSERT ON runs WHEN NEW.legacy = 0 AND NEW.coordinator_handle IS NOT NULL diff --git a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts index bcabe83cbcb5..0768807931e6 100644 --- a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts @@ -145,8 +145,9 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( launch_token_hash TEXT, assignee_handle TEXT, assignee_pane_key TEXT, - -- session: when the party is a structured session (orchestration-actor); NULL for a PTY. - assignee_actor TEXT, + -- Bare Orca session id a structured-session party is addressed by (for a /clear'd chat, its + -- lineage root's), not its session: address; NULL for a PTY. + assignee_orca_session_id TEXT, capability_hash TEXT, process_incarnation TEXT, capability_revoked_at TEXT, @@ -157,7 +158,8 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( -- so it must not count as a nesting parent. Null on rows written before v37 and for Orca's loop. creator_handle TEXT, creator_pane_key TEXT, - creator_actor TEXT, + -- Same form as assignee_orca_session_id: the id the creator is addressed by (a lineage root's). + creator_orca_session_id TEXT, host_scope TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'dispatched', 'completed', 'failed', 'circuit_broken')), diff --git a/src/main/runtime/orchestration/db/schema/migrate-v42.ts b/src/main/runtime/orchestration/db/schema/migrate-v42.ts index 1b275b29e4e4..d56b279b7b97 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v42.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v42.ts @@ -1,48 +1,48 @@ import type { OrchestrationDb } from '../orchestration-db' -import { currentRunCoordinatorActorSql } from '../runs/run-coordinator-actor' +import { currentRunCoordinatorAddressSql } from '../runs/run-coordinator-orca-session' -const ACTOR_COLUMNS = [ - ['runs', 'coordinator_actor', 'TEXT'], - ['runs', 'coordinator_actor_generation', 'INTEGER'], - ['dispatch_contexts', 'assignee_actor', 'TEXT'], - ['dispatch_contexts', 'creator_actor', 'TEXT'] +const ORCA_SESSION_ID_COLUMNS = [ + ['runs', 'coordinator_orca_session_id', 'TEXT'], + ['runs', 'coordinator_orca_session_id_generation', 'INTEGER'], + ['dispatch_contexts', 'assignee_orca_session_id', 'TEXT'], + ['dispatch_contexts', 'creator_orca_session_id', 'TEXT'] ] as const -const NEW_COORDINATOR_ADDRESS_SQL = `COALESCE(NEW.coordinator_handle, ${currentRunCoordinatorActorSql('NEW')})` +const NEW_COORDINATOR_ADDRESS_SQL = currentRunCoordinatorAddressSql('NEW') /** - * Orchestration actor columns (`session:`, see orchestration-actor): who a Run's coordinator - * and a Dispatch's assignee and creator are when that party is a structured session. PTY rows keep - * NULL and keep their handle and pane-key identity. Existing structured-worker rows get their actor - * from `backfillStructuredWorkerActors`, which runs after migrate on every open. A coordinator actor - * carries the consumer generation it was written at and counts only at that generation. + * Orca session id columns (bare ids, see orca-session-address): which structured session a Run's + * coordinator and a Dispatch's assignee and creator are when that party is one. PTY rows keep NULL + * and keep their handle and pane-key identity. Existing structured-worker rows get their id from + * `backfillStructuredWorkerOrcaSessionIds`, which runs after migrate on every open. A coordinator's + * id carries the consumer generation it was written at and counts only at that generation. + * The id is the one the agent is addressed by: for a `/clear`ed chat, its lineage root's, not the live one. * - * Dev databases stamped v42 by an earlier prototype hold `*_principal` columns instead, and ones - * stamped by an earlier build of this step lack `coordinator_actor_generation`. They are - * unsupported: the version-skew probe finds a column missing and replays the chain, which adds these - * columns and leaves the stale ones unread. An actor with no generation never counts. + * Dev databases stamped v42 by earlier builds hold `*_principal` or `*_actor` columns instead. They + * are unsupported: the version-skew probe finds a column missing and replays the chain, which adds + * these columns and leaves the stale ones unread. */ export function migrateV42(this: OrchestrationDb, current: number): void { if (current >= 42) { return } // Guarded because createTables runs first on every open and already gives a fresh database these. - for (const [table, column, type] of ACTOR_COLUMNS) { + for (const [table, column, type] of ORCA_SESSION_ID_COLUMNS) { if (!this.hasColumn(table, column)) { this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`) } } - // A Run lookup that ORs a pane-leaf match with an actor match scans every Run without this index. + // A Run lookup that ORs a pane-leaf match with an Orca session id match scans every Run without this index. this.db.exec(` - CREATE INDEX IF NOT EXISTS idx_runs_coordinator_actor - ON runs(coordinator_actor) WHERE coordinator_actor IS NOT NULL; - CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_actor - ON dispatch_contexts(assignee_actor) WHERE assignee_actor IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_runs_coordinator_orca_session_id + ON runs(coordinator_orca_session_id) WHERE coordinator_orca_session_id IS NOT NULL; + CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_orca_session_id + ON dispatch_contexts(assignee_orca_session_id) WHERE assignee_orca_session_id IS NOT NULL; `) - // A handle-less coordinator is remembered by its current actor, which is already a mailbox - // address, so every reader of this cache matches it unchanged. This step owns the trigger form: the static - // createTables SQL must stay handle-only (see create-core-tables-sql), and CREATE TRIGGER IF NOT - // EXISTS never replaces an existing database's triggers, so they are dropped and recreated by name. + // A handle-less coordinator is remembered by its session address, `session:`, so every reader + // of this cache matches it by string equality, unchanged. This step owns the trigger form: the + // static createTables SQL must stay handle-only (see create-core-tables-sql), and CREATE TRIGGER IF + // NOT EXISTS never replaces an existing database's triggers, so they are dropped and recreated by name. this.db.exec(` DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_insert; DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_update; @@ -54,7 +54,8 @@ export function migrateV42(this: OrchestrationDb, current: number): void { VALUES (NEW.id, ${NEW_COORDINATOR_ADDRESS_SQL}); END; CREATE TRIGGER trg_runs_remember_coordinator_update - AFTER UPDATE OF coordinator_handle, coordinator_actor, coordinator_actor_generation ON runs + AFTER UPDATE OF coordinator_handle, coordinator_orca_session_id, + coordinator_orca_session_id_generation ON runs WHEN NEW.legacy = 0 AND ${NEW_COORDINATOR_ADDRESS_SQL} IS NOT NULL BEGIN INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts similarity index 71% rename from src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts rename to src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts index b0c3ceea6e19..57bf53e7026a 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.test.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.test.ts @@ -5,7 +5,7 @@ import { structuredWorkerProcessIncarnation } from '../../../structured-worker-identity' import { OrchestrationDb } from '../orchestration-db' -import { backfillStructuredWorkerActors } from './structured-worker-actor-backfill' +import { backfillStructuredWorkerOrcaSessionIds } from './structured-worker-orca-session-backfill' const SESSION_A = '0d2f4b6a-8c1e-4a3b-9d5f-7e0a2c4b6d81' const SESSION_B = '1e3a5c7b-9d2f-4b4c-8e6a-0f1b3d5c7e92' @@ -13,7 +13,7 @@ const SESSION_C = '2f4b6d8c-0e3a-4c5d-9f7b-1a2c4e6d8fa3' const UNCAPPED = Number.MAX_SAFE_INTEGER const SYSTEM = { kind: 'system' } as const -describe('structured worker actor backfill', () => { +describe('structured worker Orca session id backfill', () => { let db: OrchestrationDb afterEach(() => db?.close()) @@ -35,9 +35,12 @@ describe('structured worker actor backfill', () => { }).id } - function actors(dispatchId: string): { assignee: string | null; creator: string | null } { + function orcaSessionIds(dispatchId: string): { assignee: string | null; creator: string | null } { const row = db.getDispatchContextById(dispatchId) - return { assignee: row?.assignee_actor ?? null, creator: row?.creator_actor ?? null } + return { + assignee: row?.assignee_orca_session_id ?? null, + creator: row?.creator_orca_session_id ?? null + } } it('proves a handle through the session this host recorded against it', () => { @@ -56,9 +59,9 @@ describe('structured worker actor backfill', () => { ownership: 'owned' }) - backfillStructuredWorkerActors(db.db) + backfillStructuredWorkerOrcaSessionIds(db.db) - expect(actors(handleOnly)).toEqual({ assignee: `session:${SESSION_B}`, creator: null }) + expect(orcaSessionIds(handleOnly)).toEqual({ assignee: SESSION_B, creator: null }) }) it('leaves every row it cannot tie to exactly one valid session NULL', () => { @@ -94,14 +97,14 @@ describe('structured worker actor backfill', () => { ownership: 'owned' }) - backfillStructuredWorkerActors(db.db) + backfillStructuredWorkerOrcaSessionIds(db.db) for (const id of [noRecord, invalidId, terminalInSessionTab, conflicting]) { - expect(actors(id), id).toEqual({ assignee: null, creator: null }) + expect(orcaSessionIds(id), id).toEqual({ assignee: null, creator: null }) } }) - it('fills only NULLs and never rewrites an actor a writer recorded', () => { + it('fills only NULLs and never rewrites an Orca session id a writer recorded', () => { db = new OrchestrationDb(':memory:') const handle = mintStructuredWorkerHandle() const id = dispatch({ @@ -110,15 +113,15 @@ describe('structured worker actor backfill', () => { incarnation: structuredWorkerProcessIncarnation(SESSION_A) }) db.db - .prepare('UPDATE dispatch_contexts SET assignee_actor = ? WHERE id = ?') - .run(`session:${SESSION_C}`, id) + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run(SESSION_C, id) - backfillStructuredWorkerActors(db.db) + backfillStructuredWorkerOrcaSessionIds(db.db) - expect(actors(id).assignee).toBe(`session:${SESSION_C}`) + expect(orcaSessionIds(id).assignee).toBe(SESSION_C) }) - it("fills a worker-coordinated Run over an actor an older binding's generation left behind", () => { + it("fills a worker-coordinated Run over an Orca session id an older binding's generation left behind", () => { db = new OrchestrationDb(':memory:') const runFor = (sessionId: string): string => { const handle = mintStructuredWorkerHandle() @@ -132,20 +135,21 @@ describe('structured worker actor backfill', () => { } const stale = runFor(SESSION_A) const recorded = runFor(SESSION_B) - const setActor = db.db.prepare( - `UPDATE runs SET coordinator_actor = ?, coordinator_actor_generation = consumer_generation - ? + const setOrcaSessionId = db.db.prepare( + `UPDATE runs SET coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation - ? WHERE id = ?` ) - // An older binary rebound this Run to the worker over session C's actor, which it cannot see. - setActor.run(`session:${SESSION_C}`, 1, stale) + // An older binary rebound this Run to the worker over session C's id, which it cannot see. + setOrcaSessionId.run(SESSION_C, 1, stale) // A writer recorded this one at the current generation. - setActor.run(`session:${SESSION_C}`, 0, recorded) + setOrcaSessionId.run(SESSION_C, 0, recorded) - backfillStructuredWorkerActors(db.db) + backfillStructuredWorkerOrcaSessionIds(db.db) const filled = db.getRunRaw(stale) - expect(filled?.coordinator_actor).toBe(`session:${SESSION_A}`) - expect(filled?.coordinator_actor_generation).toBe(filled?.consumer_generation) - expect(db.getRunRaw(recorded)?.coordinator_actor).toBe(`session:${SESSION_C}`) + expect(filled?.coordinator_orca_session_id).toBe(SESSION_A) + expect(filled?.coordinator_orca_session_id_generation).toBe(filled?.consumer_generation) + expect(db.getRunRaw(recorded)?.coordinator_orca_session_id).toBe(SESSION_C) }) }) diff --git a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts similarity index 56% rename from src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts rename to src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts index b18effbfec9d..59a0bdb1c7f7 100644 --- a/src/main/runtime/orchestration/db/schema/structured-worker-actor-backfill.ts +++ b/src/main/runtime/orchestration/db/schema/structured-worker-orca-session-backfill.ts @@ -1,17 +1,14 @@ import type Database from '../../../../sqlite/sync-database' -import { - formatOrchestrationActor, - sessionOrchestrationActor -} from '../../../../../shared/orchestration-actor' +import { isOrcaSessionId } from '../../../../../shared/orca-session-address' import { STRUCTURED_WORKER_HANDLE_PREFIX, STRUCTURED_WORKER_INCARNATION_PREFIX, isStructuredWorkerHandle, sessionIdFromStructuredWorkerIncarnation } from '../../../structured-worker-identity' -import { currentRunCoordinatorActorSql } from '../runs/run-coordinator-actor' +import { currentRunCoordinatorOrcaSessionIdSql } from '../runs/run-coordinator-orca-session' -const CURRENT_COORDINATOR_ACTOR_SQL = currentRunCoordinatorActorSql('runs') +const CURRENT_COORDINATOR_ORCA_SESSION_ID_SQL = currentRunCoordinatorOrcaSessionIdSql('runs') // GLOB is a case-sensitive prefix filter; the canonical predicates still decide every row. const HANDLE_GLOB = `${STRUCTURED_WORKER_HANDLE_PREFIX}*` @@ -25,21 +22,21 @@ const RECORDED_WORKER_SESSIONS_SQL = ` WHERE terminal_handle GLOB ? AND process_incarnation GLOB ?` /** - * Fills the actor on rows that provably belong to a structured worker session and have none: a - * `structured:` process incarnation, or a `structworker_` handle this host recorded - * against such an incarnation. Every other row stays NULL, PTY rows included, and evidence naming - * more than one session proves none. Pane keys are never read: a pane outlives the agent in it. + * Fills the Orca session id on rows that provably belong to a structured worker session and have + * none: a `structured:` process incarnation, or a `structworker_` handle this host + * recorded against such an incarnation. Every other row stays NULL, PTY rows included, and evidence + * naming more than one session proves none. Pane keys are never read: a pane outlives the agent in it. * * Both markers are minted only for a local, non-WSL session (`structuredWorkerHostScope`), so the * rows carrying them were written by this host. * * Runs after migrate on every open, not only once at v42: a binary rolled back past v42 keeps - * writing structured-worker rows without an actor after user_version is already 42. It fills only - * rows with no actor that counts, so an actor a writer recorded is never rewritten. + * writing structured-worker rows without an Orca session id after user_version is already 42. It + * fills only rows with no id that counts, so an id a writer recorded is never rewritten. */ -export function backfillStructuredWorkerActors(db: Database.Database): void { +export function backfillStructuredWorkerOrcaSessionIds(db: Database.Database): void { let recordedSessions: Map> | undefined - const actorFor = (handle: unknown, incarnation: unknown): string | null => { + const orcaSessionIdFor = (handle: unknown, incarnation: unknown): string | null => { if (incarnation != null && typeof incarnation !== 'string') { return null } @@ -59,57 +56,60 @@ export function backfillStructuredWorkerActors(db: Database.Database): void { } } const [sessionId, ...others] = sessions - const actor = sessionId && others.length === 0 ? sessionOrchestrationActor(sessionId) : null - return actor ? formatOrchestrationActor(actor) : null + return sessionId && others.length === 0 && isOrcaSessionId(sessionId) ? sessionId : null } const assignees = db .prepare( `SELECT id, assignee_handle, process_incarnation FROM dispatch_contexts - WHERE assignee_actor IS NULL AND (process_incarnation GLOB ? OR assignee_handle GLOB ?)` + WHERE assignee_orca_session_id IS NULL + AND (process_incarnation GLOB ? OR assignee_handle GLOB ?)` ) .all(INCARNATION_GLOB, HANDLE_GLOB) const setAssignee = db.prepare( - 'UPDATE dispatch_contexts SET assignee_actor = ? WHERE id = ? AND assignee_actor IS NULL' + `UPDATE dispatch_contexts SET assignee_orca_session_id = ? + WHERE id = ? AND assignee_orca_session_id IS NULL` ) for (const row of assignees) { - const actor = actorFor(row.assignee_handle, row.process_incarnation) - if (actor && typeof row.id === 'string') { - setAssignee.run(actor, row.id) + const orcaSessionId = orcaSessionIdFor(row.assignee_handle, row.process_incarnation) + if (orcaSessionId && typeof row.id === 'string') { + setAssignee.run(orcaSessionId, row.id) } } const creators = db .prepare( `SELECT id, creator_handle FROM dispatch_contexts - WHERE creator_actor IS NULL AND creator_handle GLOB ?` + WHERE creator_orca_session_id IS NULL AND creator_handle GLOB ?` ) .all(HANDLE_GLOB) const setCreator = db.prepare( - 'UPDATE dispatch_contexts SET creator_actor = ? WHERE id = ? AND creator_actor IS NULL' + `UPDATE dispatch_contexts SET creator_orca_session_id = ? + WHERE id = ? AND creator_orca_session_id IS NULL` ) for (const row of creators) { - const actor = actorFor(row.creator_handle, null) - if (actor && typeof row.id === 'string') { - setCreator.run(actor, row.id) + const orcaSessionId = orcaSessionIdFor(row.creator_handle, null) + if (orcaSessionId && typeof row.id === 'string') { + setCreator.run(orcaSessionId, row.id) } } - // A coordinator actor left at an older generation counts as none, so the handle's session fills it. + // A coordinator id left at an older generation counts as none, so the handle's session fills it. const coordinators = db .prepare( `SELECT id, coordinator_handle FROM runs - WHERE ${CURRENT_COORDINATOR_ACTOR_SQL} IS NULL AND coordinator_handle GLOB ?` + WHERE ${CURRENT_COORDINATOR_ORCA_SESSION_ID_SQL} IS NULL AND coordinator_handle GLOB ?` ) .all(HANDLE_GLOB) const setCoordinator = db.prepare( - `UPDATE runs SET coordinator_actor = ?, coordinator_actor_generation = consumer_generation - WHERE id = ? AND ${CURRENT_COORDINATOR_ACTOR_SQL} IS NULL` + `UPDATE runs SET coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation + WHERE id = ? AND ${CURRENT_COORDINATOR_ORCA_SESSION_ID_SQL} IS NULL` ) for (const row of coordinators) { - const actor = actorFor(row.coordinator_handle, null) - if (actor && typeof row.id === 'string') { - setCoordinator.run(actor, row.id) + const orcaSessionId = orcaSessionIdFor(row.coordinator_handle, null) + if (orcaSessionId && typeof row.id === 'string') { + setCoordinator.run(orcaSessionId, row.id) } } } diff --git a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-actor.test.ts b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-orca-session.test.ts similarity index 60% rename from src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-actor.test.ts rename to src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-orca-session.test.ts index 199f23569a51..b43c996742f8 100644 --- a/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-actor.test.ts +++ b/src/main/runtime/orchestration/db/worker-dispatch/worker-dispatch-assignee-orca-session.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' -import { sessionOrchestrationActor } from '../../../../../shared/orchestration-actor' +import { isOrcaSessionId } from '../../../../../shared/orca-session-address' import { mintStructuredWorkerHandle } from '../../../structured-worker-identity' import { OrchestrationDb } from '../orchestration-db' -const EARLIER_ACTOR = 'session:7d9f1b3e-5a2c-4e6b-8f0a-1c3e5a7b9d42' +const EARLIER_ORCA_SESSION_ID = '7d9f1b3e-5a2c-4e6b-8f0a-1c3e5a7b9d42' const WORKER_PANE = 'tab_worker:88888888-8888-4888-8888-888888888888' describe('assignee identity writers', () => { @@ -14,8 +14,8 @@ describe('assignee identity writers', () => { db = undefined }) - /** A starting Dispatch whose row already names an actor, standing in for any earlier writer. */ - function startingDispatchWithActor(target: OrchestrationDb): string { + /** A starting Dispatch whose row already names an Orca session id, standing in for any earlier writer. */ + function startingDispatchWithOrcaSessionId(target: OrchestrationDb): string { const task = target.createTask({ runId: 'run_legacy_local', spec: 'worker' }) const started = target.createStartingWorkerDispatch({ creator: { kind: 'system' }, @@ -24,14 +24,14 @@ describe('assignee identity writers', () => { startOptions: {} }) target.db - .prepare('UPDATE dispatch_contexts SET assignee_actor = ? WHERE id = ?') - .run(EARLIER_ACTOR, started.dispatch.id) + .prepare('UPDATE dispatch_contexts SET assignee_orca_session_id = ? WHERE id = ?') + .run(EARLIER_ORCA_SESSION_ID, started.dispatch.id) return started.dispatch.id } - it('clears the actor when worker authority names the assignee', () => { + it('clears the Orca session id when worker authority names the assignee', () => { db = new OrchestrationDb(':memory:') - const dispatchId = startingDispatchWithActor(db) + const dispatchId = startingDispatchWithOrcaSessionId(db) db.prepareStartingWorkerAuthority({ dispatchId, @@ -45,13 +45,13 @@ describe('assignee identity writers', () => { expect(db.getDispatchContextById(dispatchId)).toMatchObject({ assignee_handle: 'term_worker', - assignee_actor: null + assignee_orca_session_id: null }) }) - it('clears the actor when a failed start records the terminal it owned', () => { + it('clears the Orca session id when a failed start records the terminal it owned', () => { db = new OrchestrationDb(':memory:') - const dispatchId = startingDispatchWithActor(db) + const dispatchId = startingDispatchWithOrcaSessionId(db) db.recordCreatedWorkerTerminalCustody({ dispatchId, handle: 'term_worker', @@ -65,12 +65,12 @@ describe('assignee identity writers', () => { expect(db.getDispatchContextById(dispatchId)).toMatchObject({ assignee_handle: 'term_worker', - assignee_actor: null + assignee_orca_session_id: null }) }) - it('refuses a minted structured-worker handle as a session id', () => { - // Ties the codec's handle-prefix refusal to the handle this runtime actually mints. - expect(sessionOrchestrationActor(mintStructuredWorkerHandle())).toBeNull() + it('refuses a minted structured-worker handle as an Orca session id', () => { + // Ties the handle-prefix refusal to the handle this runtime actually mints. + expect(isOrcaSessionId(mintStructuredWorkerHandle())).toBe(false) }) }) 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 a3a45417a8bd..166ccd7193e6 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 @@ -54,7 +54,7 @@ export function prepareStartingWorkerAuthority( .prepare( `UPDATE dispatch_contexts SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, - assignee_actor = NULL, host_scope = ?, + assignee_orca_session_id = NULL, host_scope = ?, capability_hash = ?, launch_token_hash = COALESCE(launch_token_hash, ?), capability_revoked_at = NULL, consumer_generation = consumer_generation + 1 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 3e6e533be82b..c9b9b5d0afdf 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 @@ -22,7 +22,7 @@ export function recordFailedStartDispatchIdentity( .prepare( `UPDATE dispatch_contexts SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, host_scope = ?, - assignee_actor = NULL + assignee_orca_session_id = NULL WHERE id = ? AND status = 'failed' AND capability_hash IS NULL` ) .run( diff --git a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts similarity index 69% rename from src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts rename to src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts index 6589b49878b9..e0a65118112a 100644 --- a/src/main/runtime/orchestration/orchestration-actor-column-migration.test.ts +++ b/src/main/runtime/orchestration/orchestration-orca-session-column-migration.test.ts @@ -10,16 +10,23 @@ import { } from '../structured-worker-identity' import { OrchestrationDb } from './db' import { SCHEMA_VERSION } from './db/contract-constants' +import { formatOrcaSessionAddress } from '../../../shared/orca-session-address' import { RUN_PANE_KEY_MATCH_SUFFIX_SQL } from './db/pane-key-match' import { - currentRunCoordinatorActor, - currentRunCoordinatorActorSql -} from './db/runs/run-coordinator-actor' + currentRunCoordinatorOrcaSessionId, + currentRunCoordinatorOrcaSessionIdSql +} from './db/runs/run-coordinator-orca-session' import { resolveOrchestrationMigrationStartVersion } from './orchestration-schema-version-skew' const SESSION_ID = '5f0c1d9e-2b7a-4c3e-8f61-0a9d2e7b4c11' -const SESSION_ACTOR = `session:${SESSION_ID}` -const CHAT_SESSION_ACTOR = 'session:9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93' +const CHAT_SESSION_ID = '9a4e7c1b-3d2f-4b6a-8e5c-7f1d0b2a6c93' +const CHAT_SESSION_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) +const ORCA_SESSION_ID_COLUMNS = [ + 'assignee_orca_session_id', + 'coordinator_orca_session_id', + 'coordinator_orca_session_id_generation', + 'creator_orca_session_id' +] const COORDINATOR_PANE = 'tab_coord:11111111-1111-4111-8111-111111111111' const PTY_WORKER_PANE = 'tab_pty:22222222-2222-4222-8222-222222222222' const NESTED_PANE = 'tab_nested:33333333-3333-4333-8333-333333333333' @@ -103,34 +110,34 @@ function seedStructuredAndPtyRows(db: OrchestrationDb): SeededRows { } /** Strips a current database back to the shape main stamps at v41 and stamps `version`. */ -function stripActorSchema(path: string, version: number): void { +function stripOrcaSessionSchema(path: string, version: number): void { const raw = new Database(path) raw.exec(` - DROP INDEX idx_runs_coordinator_actor; - DROP INDEX idx_dispatch_assignee_actor; + DROP INDEX idx_runs_coordinator_orca_session_id; + DROP INDEX idx_dispatch_assignee_orca_session_id; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; - ALTER TABLE runs DROP COLUMN coordinator_actor; - ALTER TABLE runs DROP COLUMN coordinator_actor_generation; - ALTER TABLE dispatch_contexts DROP COLUMN assignee_actor; - ALTER TABLE dispatch_contexts DROP COLUMN creator_actor; + 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} `) raw.pragma(`user_version = ${version}`) raw.close() } -/** The plan for finding a caller's Runs by pane leaf or by coordinator actor in one statement. */ +/** The plan for finding a caller's Runs by pane leaf or by coordinator Orca session id in one statement. */ function coordinatorLookupPlan(db: Database.Database): string { return db .prepare( `EXPLAIN QUERY PLAN SELECT id FROM runs WHERE legacy = 0 AND ( (coordinator_pane_key IS NOT NULL AND ${RUN_PANE_KEY_MATCH_SUFFIX_SQL} = ?) - OR coordinator_actor = ? + OR coordinator_orca_session_id = ? )` ) - .all('leaf', CHAT_SESSION_ACTOR) + .all('leaf', CHAT_SESSION_ID) .map((row) => String(row.detail)) .join(' | ') } @@ -146,7 +153,7 @@ function coordinatorTriggerSql(db: Database.Database): string[] { .map((row) => String(row.sql)) } -function actorColumns(db: Database.Database): string[] { +function orcaSessionColumns(db: Database.Database): string[] { return db .prepare( `SELECT name FROM pragma_table_info('runs') @@ -154,7 +161,7 @@ function actorColumns(db: Database.Database): string[] { ) .all() .map((column) => String(column.name)) - .filter((name) => name.endsWith('_actor')) + .filter((name) => name.includes('_orca_session_id')) .sort() } @@ -169,7 +176,7 @@ function coordinatorAddresses(db: Database.Database, runIds: string[]): string[] .sort() } -describe('orchestration actor column migration', () => { +describe('orchestration Orca session id column migration', () => { const tempRoots: string[] = [] afterEach(() => { @@ -179,17 +186,17 @@ describe('orchestration actor column migration', () => { }) function tempDbPath(): string { - const root = mkdtempSync(join(tmpdir(), 'orca-actor-column-migration-')) + const root = mkdtempSync(join(tmpdir(), 'orca-session-column-migration-')) tempRoots.push(root) return join(root, 'orchestration.db') } - it('starts a v41 database at v41 and gives exactly its structured-worker rows an actor', () => { + it('starts a v41 database at v41 and gives exactly its structured-worker rows an Orca session id', () => { const path = tempDbPath() const seed = new OrchestrationDb(path) const rows = seedStructuredAndPtyRows(seed) seed.close() - stripActorSchema(path, 41) + stripOrcaSessionSchema(path, 41) const probe = new Database(path) try { @@ -203,29 +210,33 @@ describe('orchestration actor column migration', () => { try { expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect(db.getDispatchContextById(rows.structuredDispatchId)).toMatchObject({ - assignee_actor: SESSION_ACTOR, - creator_actor: null + assignee_orca_session_id: SESSION_ID, + creator_orca_session_id: null }) expect(db.getDispatchContextById(rows.ptyDispatchId)).toMatchObject({ - assignee_actor: null, - creator_actor: null + assignee_orca_session_id: null, + creator_orca_session_id: null }) expect(db.getDispatchContextById(rows.nestedDispatchId)).toMatchObject({ - assignee_actor: null, - creator_actor: SESSION_ACTOR + assignee_orca_session_id: null, + creator_orca_session_id: SESSION_ID }) expect( - db.db.prepare('SELECT id FROM dispatch_contexts WHERE assignee_actor IS NOT NULL').all() + db.db + .prepare('SELECT id FROM dispatch_contexts WHERE assignee_orca_session_id IS NOT NULL') + .all() ).toEqual([{ id: rows.structuredDispatchId }]) - expect(db.getRunRaw(rows.ptyRunId)?.coordinator_actor).toBeNull() - expect(db.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) + expect(db.getRunRaw(rows.ptyRunId)?.coordinator_orca_session_id).toBeNull() + expect(db.getRunRaw(rows.structuredRunId)?.coordinator_orca_session_id).toBe(SESSION_ID) // A handle-bearing coordinator stays remembered by its handle alone, as before v42. expect(coordinatorAddresses(db.db, [rows.ptyRunId, rows.structuredRunId])).toEqual( [`${rows.ptyRunId} term_coord`, `${rows.structuredRunId} ${rows.workerHandle}`].sort() ) // CREATE TRIGGER IF NOT EXISTS alone would have kept the handle-only form here. for (const sql of coordinatorTriggerSql(db.db)) { - expect(sql).toContain('NEW.coordinator_actor_generation = NEW.consumer_generation') + expect(sql).toContain( + 'NEW.coordinator_orca_session_id_generation = NEW.consumer_generation' + ) } } finally { db.close() @@ -237,7 +248,7 @@ describe('orchestration actor column migration', () => { const seed = new OrchestrationDb(path) const rows = seedStructuredAndPtyRows(seed) seed.close() - stripActorSchema(path, 40) + stripOrcaSessionSchema(path, 40) const raw = new Database(path) // The v40 shape of the one object v41 changed: a unique outstanding-delivery index. raw.exec(` @@ -256,15 +267,15 @@ describe('orchestration actor column migration', () => { const db = new OrchestrationDb(path) try { expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) - expect(actorColumns(db.db)).toEqual(['assignee_actor', 'coordinator_actor', 'creator_actor']) + expect(orcaSessionColumns(db.db)).toEqual(ORCA_SESSION_ID_COLUMNS) const index = db.db .prepare("SELECT sql FROM sqlite_master WHERE name = 'idx_deliveries_one_outstanding'") .get() expect(String(index?.sql)).not.toContain('UNIQUE') - expect(db.getDispatchContextById(rows.structuredDispatchId)?.assignee_actor).toBe( - SESSION_ACTOR + expect(db.getDispatchContextById(rows.structuredDispatchId)?.assignee_orca_session_id).toBe( + SESSION_ID ) - expect(db.getDispatchContextById(rows.ptyDispatchId)?.assignee_actor).toBeNull() + expect(db.getDispatchContextById(rows.ptyDispatchId)?.assignee_orca_session_id).toBeNull() } finally { db.close() } @@ -275,7 +286,7 @@ describe('orchestration actor column migration', () => { const seed = new OrchestrationDb(path) const rows = seedStructuredAndPtyRows(seed) seed.close() - stripActorSchema(path, 27) + stripOrcaSessionSchema(path, 27) const raw = new Database(path) raw.exec(` DROP TRIGGER trg_runs_remember_coordinator_insert; @@ -285,34 +296,36 @@ describe('orchestration actor column migration', () => { raw.close() // createTables installs its static triggers before this chain's v40 step inserts into runs, so - // a static form naming coordinator_actor would fail to prepare here. + // a static form naming coordinator_orca_session_id would fail to prepare here. const db = new OrchestrationDb(path) try { expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) for (const sql of coordinatorTriggerSql(db.db)) { - expect(sql).toContain('NEW.coordinator_actor_generation = NEW.consumer_generation') + expect(sql).toContain( + 'NEW.coordinator_orca_session_id_generation = NEW.consumer_generation' + ) } - expect(db.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) + expect(db.getRunRaw(rows.structuredRunId)?.coordinator_orca_session_id).toBe(SESSION_ID) expect(coordinatorAddresses(db.db, [rows.ptyRunId])).toEqual([`${rows.ptyRunId} term_coord`]) } finally { db.close() } }) - it('lets a v41 binary read and write a v42 database with actors in it', () => { + it('lets a v41 binary read and write a v42 database with Orca session ids in it', () => { const path = tempDbPath() const seed = new OrchestrationDb(path) const rows = seedStructuredAndPtyRows(seed) seed.close() const upgraded = new OrchestrationDb(path) - expect(upgraded.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) + expect(upgraded.getRunRaw(rows.structuredRunId)?.coordinator_orca_session_id).toBe(SESSION_ID) upgraded.db .prepare( `INSERT INTO runs ( - id, objective, coordinator_actor, coordinator_actor_generation, consumer_generation, legacy + id, objective, coordinator_orca_session_id, coordinator_orca_session_id_generation, consumer_generation, legacy ) VALUES ('run_session', 'session coordinator', ?, 1, 1, 0)` ) - .run(CHAT_SESSION_ACTOR) + .run(CHAT_SESSION_ID) upgraded.close() // A raw connection stands in for the v41 binary; each statement below is v41's own SQL. @@ -347,7 +360,7 @@ describe('orchestration actor column migration', () => { VALUES ('run_v41', 'written by v41', 'term_v41', 'tab_v41:44444444-4444-4444-8444-444444444444', 1, 0)` ) .run() - // An older binary rebinding a structured-coordinated Run cannot clear an actor it cannot see. + // An older binary rebinding a structured-coordinated Run cannot clear an id it cannot see. v41 .prepare( `UPDATE runs SET coordinator_handle = ?, coordinator_pane_key = ?, @@ -358,14 +371,13 @@ describe('orchestration actor column migration', () => { v41.exec(`INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) SELECT id, coordinator_handle FROM runs WHERE legacy = 0 AND coordinator_handle IS NOT NULL`) - expect(v41.prepare('SELECT coordinator_actor FROM runs WHERE id = ?').get('run_v41')).toEqual( - { - coordinator_actor: null - } + const readOrcaSessionId = v41.prepare( + 'SELECT coordinator_orca_session_id FROM runs WHERE id = ?' ) - expect( - v41.prepare('SELECT coordinator_actor FROM runs WHERE id = ?').get(rows.structuredRunId) - ).toEqual({ coordinator_actor: SESSION_ACTOR }) + expect(readOrcaSessionId.get('run_v41')).toEqual({ coordinator_orca_session_id: null }) + expect(readOrcaSessionId.get(rows.structuredRunId)).toEqual({ + coordinator_orca_session_id: SESSION_ID + }) expect(coordinatorAddresses(v41, ['run_v41', rows.structuredRunId])).toEqual( [ `${rows.structuredRunId} ${rows.workerHandle}`, @@ -375,7 +387,7 @@ describe('orchestration actor column migration', () => { ) // The v42 trigger form survives v41's IF NOT EXISTS create. for (const sql of coordinatorTriggerSql(v41)) { - expect(sql).toContain('coordinator_actor') + expect(sql).toContain('coordinator_orca_session_id') } } finally { v41.close() @@ -385,13 +397,13 @@ describe('orchestration actor column migration', () => { try { expect(rolledForward.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) expect(rolledForward.getRun('run_v41')?.coordinator_handle).toBe('term_v41') - expect(rolledForward.getRunMailboxOwnerIdsForHandle(CHAT_SESSION_ACTOR)).toEqual([ + expect(rolledForward.getRunMailboxOwnerIdsForHandle(CHAT_SESSION_ADDRESS)).toEqual([ 'run_session' ]) - // v41's rebind bumped the generation, so the actor it could not clear no longer counts. + // v41's rebind bumped the generation, so the id it could not clear no longer counts. const rebound = rolledForward.getRunRaw(rows.structuredRunId) - expect(rebound?.coordinator_actor).toBe(SESSION_ACTOR) - expect(rebound && currentRunCoordinatorActor(rebound)).toBeNull() + expect(rebound?.coordinator_orca_session_id).toBe(SESSION_ID) + expect(rebound && currentRunCoordinatorOrcaSessionId(rebound)).toBeNull() } finally { rolledForward.close() } @@ -400,32 +412,36 @@ describe('orchestration actor 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 actor yet, which is also the shape a binary rolled back past v42 writes. + // No writer records an Orca session id yet, which is also the shape a binary rolled back past v42 writes. const rows = seedStructuredAndPtyRows(first) - expect(first.getDispatchContextById(rows.structuredDispatchId)?.assignee_actor).toBeNull() + expect( + first.getDispatchContextById(rows.structuredDispatchId)?.assignee_orca_session_id + ).toBeNull() first.close() const reopened = new OrchestrationDb(path) try { - expect(reopened.getDispatchContextById(rows.structuredDispatchId)?.assignee_actor).toBe( - SESSION_ACTOR - ) - expect(reopened.getDispatchContextById(rows.nestedDispatchId)?.creator_actor).toBe( - SESSION_ACTOR + expect( + reopened.getDispatchContextById(rows.structuredDispatchId)?.assignee_orca_session_id + ).toBe(SESSION_ID) + expect(reopened.getDispatchContextById(rows.nestedDispatchId)?.creator_orca_session_id).toBe( + SESSION_ID ) - expect(reopened.getRunRaw(rows.structuredRunId)?.coordinator_actor).toBe(SESSION_ACTOR) - expect(reopened.getDispatchContextById(rows.ptyDispatchId)?.assignee_actor).toBeNull() + expect(reopened.getRunRaw(rows.structuredRunId)?.coordinator_orca_session_id).toBe(SESSION_ID) + expect( + reopened.getDispatchContextById(rows.ptyDispatchId)?.assignee_orca_session_id + ).toBeNull() } finally { reopened.close() } }) - it('drives a dev database stamped v42 with prototype principal columns to add the actors', () => { + it('drives a dev database stamped v42 with prototype principal columns to add the Orca session ids', () => { const path = tempDbPath() const seed = new OrchestrationDb(path) const rows = seedStructuredAndPtyRows(seed) seed.close() - stripActorSchema(path, 41) + stripOrcaSessionSchema(path, 41) const raw = new Database(path) // An unmerged prototype stamped v42 with differently named columns and triggers over them. raw.exec(` @@ -460,10 +476,10 @@ describe('orchestration actor column migration', () => { const db = new OrchestrationDb(path) try { expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) - expect(actorColumns(db.db)).toEqual(['assignee_actor', 'coordinator_actor', 'creator_actor']) + expect(orcaSessionColumns(db.db)).toEqual(ORCA_SESSION_ID_COLUMNS) expect(coordinatorTriggerSql(db.db).join('\n')).not.toContain('principal') - expect(db.getDispatchContextById(rows.structuredDispatchId)?.assignee_actor).toBe( - SESSION_ACTOR + expect(db.getDispatchContextById(rows.structuredDispatchId)?.assignee_orca_session_id).toBe( + SESSION_ID ) expect(() => db.createRun({ @@ -477,16 +493,16 @@ describe('orchestration actor column migration', () => { } }) - it('stops counting a chat coordinator actor once a v41 binary rebinds and then unbinds the Run', () => { + it("stops counting a chat coordinator's Orca session id once a v41 binary rebinds and then unbinds the Run", () => { const path = tempDbPath() const seeded = new OrchestrationDb(path) seeded.db .prepare( `INSERT INTO runs ( - id, objective, coordinator_actor, coordinator_actor_generation, consumer_generation, legacy + id, objective, coordinator_orca_session_id, coordinator_orca_session_id_generation, consumer_generation, legacy ) VALUES ('run_chat', 'chat coordinated', ?, 1, 1, 0)` ) - .run(CHAT_SESSION_ACTOR) + .run(CHAT_SESSION_ID) // The cache row this binding wrote; a later open must not be able to write it back. seeded.db.prepare('DELETE FROM run_coordinator_handles WHERE run_id = ?').run('run_chat') seeded.close() @@ -513,14 +529,15 @@ describe('orchestration actor column migration', () => { expect( v41 .prepare( - `SELECT coordinator_handle, coordinator_pane_key, coordinator_actor, consumer_generation + `SELECT coordinator_handle, coordinator_pane_key, coordinator_orca_session_id, + consumer_generation FROM runs WHERE id = ?` ) .get('run_chat') ).toEqual({ coordinator_handle: null, coordinator_pane_key: null, - coordinator_actor: CHAT_SESSION_ACTOR, + coordinator_orca_session_id: CHAT_SESSION_ID, consumer_generation: 3 }) v41.close() @@ -528,11 +545,11 @@ describe('orchestration actor column migration', () => { const reopened = new OrchestrationDb(path) try { const run = reopened.getRunRaw('run_chat') - expect(run && currentRunCoordinatorActor(run)).toBeNull() + expect(run && currentRunCoordinatorOrcaSessionId(run)).toBeNull() expect( reopened.db - .prepare(`SELECT id FROM runs WHERE ${currentRunCoordinatorActorSql('runs')} = ?`) - .all(CHAT_SESSION_ACTOR) + .prepare(`SELECT id FROM runs WHERE ${currentRunCoordinatorOrcaSessionIdSql('runs')} = ?`) + .all(CHAT_SESSION_ID) ).toEqual([]) expect(coordinatorAddresses(reopened.db, ['run_chat'])).toEqual(['run_chat term_taker']) } finally { @@ -540,12 +557,12 @@ describe('orchestration actor column migration', () => { } }) - it('finds Runs by coordinator actor through an index on fresh and upgraded databases', () => { + it('finds Runs by coordinator Orca session id through an index on fresh and upgraded databases', () => { const expectIndexedLookup = (path: string): void => { const db = new OrchestrationDb(path) try { const plan = coordinatorLookupPlan(db.db) - expect(plan).toContain('USING INDEX idx_runs_coordinator_actor') + expect(plan).toContain('USING INDEX idx_runs_coordinator_orca_session_id') expect(plan).not.toContain('SCAN runs') } finally { db.close() @@ -557,22 +574,56 @@ describe('orchestration actor column migration', () => { const seed = new OrchestrationDb(upgradedPath) seedStructuredAndPtyRows(seed) seed.close() - stripActorSchema(upgradedPath, 41) + stripOrcaSessionSchema(upgradedPath, 41) expectIndexedLookup(upgradedPath) }) - it('replays a database stamped v42 before the coordinator actor carried its generation', () => { + it('replays a dev database stamped v42 with the earlier *_actor columns to add the Orca session ids', () => { const path = tempDbPath() const seed = new OrchestrationDb(path) const rows = seedStructuredAndPtyRows(seed) seed.close() + stripOrcaSessionSchema(path, 41) const raw = new Database(path) + // An earlier build of this step stamped v42 with `session:` values in differently named + // columns, their indexes, and address triggers over them. + const earlierAddress = `COALESCE(NEW.coordinator_handle, (CASE WHEN + NEW.coordinator_actor_generation = NEW.consumer_generation THEN NEW.coordinator_actor END))` raw.exec(` + ALTER TABLE runs ADD COLUMN coordinator_actor TEXT; + ALTER TABLE runs ADD COLUMN coordinator_actor_generation INTEGER; + ALTER TABLE dispatch_contexts ADD COLUMN assignee_actor TEXT; + ALTER TABLE dispatch_contexts ADD COLUMN creator_actor TEXT; + CREATE INDEX idx_runs_coordinator_actor + ON runs(coordinator_actor) WHERE coordinator_actor IS NOT NULL; + CREATE INDEX idx_dispatch_assignee_actor + ON dispatch_contexts(assignee_actor) WHERE assignee_actor IS NOT NULL; DROP TRIGGER trg_runs_remember_coordinator_insert; DROP TRIGGER trg_runs_remember_coordinator_update; - ALTER TABLE runs DROP COLUMN coordinator_actor_generation; - ${HANDLE_ONLY_COORDINATOR_TRIGGERS_SQL} + CREATE TRIGGER trg_runs_remember_coordinator_insert + AFTER INSERT ON runs + WHEN NEW.legacy = 0 AND ${earlierAddress} IS NOT NULL + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + VALUES (NEW.id, ${earlierAddress}); + END; + CREATE TRIGGER trg_runs_remember_coordinator_update + AFTER UPDATE OF coordinator_handle, coordinator_actor, coordinator_actor_generation ON runs + WHEN NEW.legacy = 0 AND ${earlierAddress} IS NOT NULL + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + VALUES (NEW.id, ${earlierAddress}); + END; `) + raw + .prepare( + `INSERT INTO runs ( + id, objective, coordinator_actor, coordinator_actor_generation, consumer_generation, legacy + ) VALUES ('run_earlier_chat', 'chat coordinated', ?, 1, 1, 0)` + ) + .run(CHAT_SESSION_ADDRESS) + // The cache row the earlier trigger wrote; only the stale column could write it back. + raw.prepare('DELETE FROM run_coordinator_handles WHERE run_id = ?').run('run_earlier_chat') raw.pragma('user_version = 42') try { expect(resolveOrchestrationMigrationStartVersion(raw, 42, SCHEMA_VERSION)).toBe(6) @@ -583,11 +634,32 @@ describe('orchestration actor column migration', () => { const db = new OrchestrationDb(path) try { expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect(orcaSessionColumns(db.db)).toEqual(ORCA_SESSION_ID_COLUMNS) for (const sql of coordinatorTriggerSql(db.db)) { - expect(sql).toContain('NEW.coordinator_actor_generation = NEW.consumer_generation') + expect(sql).toContain( + 'NEW.coordinator_orca_session_id_generation = NEW.consumer_generation' + ) + expect(sql).not.toContain('coordinator_actor') } + expect(db.getDispatchContextById(rows.structuredDispatchId)?.assignee_orca_session_id).toBe( + SESSION_ID + ) const run = db.getRunRaw(rows.structuredRunId) - expect(run && currentRunCoordinatorActor(run)).toBe(SESSION_ACTOR) + expect(run && currentRunCoordinatorOrcaSessionId(run)).toBe(SESSION_ID) + // The stale column stays where it was and nothing reads it. + expect( + db.db + .prepare('SELECT coordinator_actor, coordinator_orca_session_id FROM runs WHERE id = ?') + .get('run_earlier_chat') + ).toEqual({ coordinator_actor: CHAT_SESSION_ADDRESS, coordinator_orca_session_id: null }) + expect(coordinatorAddresses(db.db, ['run_earlier_chat'])).toEqual([]) + expect(() => + db.createRun({ + objective: 'after the replay', + coordinatorHandle: 'term_after', + coordinatorPaneKey: 'tab_after:55555555-5555-4555-8555-555555555555' + }) + ).not.toThrow() } finally { db.close() } diff --git a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts index 021b95df436d..5d44066ca8f9 100644 --- a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts +++ b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts @@ -44,10 +44,10 @@ const VERSIONED_POST_V6_COLUMNS = [ { version: 37, table: 'dispatch_contexts', column: 'creator_handle' }, { version: 37, table: 'dispatch_contexts', column: 'creator_pane_key' }, { version: 40, table: 'remote_dispatch_attachments', column: 'home_run_id' }, - { version: 42, table: 'runs', column: 'coordinator_actor' }, - { version: 42, table: 'runs', column: 'coordinator_actor_generation' }, - { version: 42, table: 'dispatch_contexts', column: 'assignee_actor' }, - { version: 42, table: 'dispatch_contexts', column: 'creator_actor' } + { version: 42, table: 'runs', column: 'coordinator_orca_session_id' }, + { version: 42, table: 'runs', column: 'coordinator_orca_session_id_generation' }, + { version: 42, table: 'dispatch_contexts', column: 'assignee_orca_session_id' }, + { version: 42, table: 'dispatch_contexts', column: 'creator_orca_session_id' } ] as const // Why: v34 shipped without these two, so a v34 stamp proves nothing about them; v35 repairs both diff --git a/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts b/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts similarity index 64% rename from src/main/runtime/orchestration/run-coordinator-actor-address.test.ts rename to src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts index da49af144ac2..c37b35f4f5df 100644 --- a/src/main/runtime/orchestration/run-coordinator-actor-address.test.ts +++ b/src/main/runtime/orchestration/run-coordinator-orca-session-address.test.ts @@ -3,19 +3,19 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { - formatOrchestrationActor, - parseOrchestrationActor -} from '../../../shared/orchestration-actor' + formatOrcaSessionAddress, + parseOrcaSessionAddress +} from '../../../shared/orca-session-address' import { mintStructuredWorkerHandle, mintStructuredWorkerPaneKey, structuredWorkerProcessIncarnation } from '../structured-worker-identity' import { OrchestrationDb } from './db' -import { backfillStructuredWorkerActors } from './db/schema/structured-worker-actor-backfill' +import { backfillStructuredWorkerOrcaSessionIds } from './db/schema/structured-worker-orca-session-backfill' const CHAT_SESSION_ID = '3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14' -const CHAT_ACTOR = formatOrchestrationActor({ kind: 'session', id: CHAT_SESSION_ID }) +const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) const WORKER_SESSION_ID = '4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25' const PTY_PANE = 'tab_pty:66666666-6666-4666-8666-666666666666' @@ -32,13 +32,14 @@ function insertSessionCoordinatedRun(db: OrchestrationDb, runId: string): void { db.db .prepare( `INSERT INTO runs ( - id, objective, coordinator_actor, coordinator_actor_generation, consumer_generation, legacy + id, objective, coordinator_orca_session_id, coordinator_orca_session_id_generation, + consumer_generation, legacy ) VALUES (?, 'coordinated by a structured session', ?, 1, 1, 0)` ) - .run(runId, CHAT_ACTOR) + .run(runId, CHAT_SESSION_ID) } -describe('Run coordinator actor address', () => { +describe('Run coordinator Orca session address', () => { let db: OrchestrationDb | undefined const tempRoots: string[] = [] @@ -50,29 +51,29 @@ describe('Run coordinator actor address', () => { } }) - it('remembers a handle-less session coordinator by its actor address', () => { + it('remembers a handle-less session coordinator by the address derived from its bare id', () => { db = new OrchestrationDb(':memory:') insertSessionCoordinatedRun(db, 'run_session') - const stored = db.getRunRaw('run_session')?.coordinator_actor ?? null - const actor = parseOrchestrationActor(stored) - expect(actor).toEqual({ kind: 'session', id: CHAT_SESSION_ID }) - expect(actor && formatOrchestrationActor(actor)).toBe(stored) - expect(addressesFor(db, 'run_session')).toEqual([CHAT_ACTOR]) - expect(db.getRunMailboxOwnerIdsForHandle(CHAT_ACTOR)).toEqual(['run_session']) + // The column holds the bare id; only the remembered address carries the session: prefix. + expect(db.getRunRaw('run_session')?.coordinator_orca_session_id).toBe(CHAT_SESSION_ID) + expect(addressesFor(db, 'run_session')).toEqual([CHAT_ADDRESS]) + expect(parseOrcaSessionAddress(addressesFor(db, 'run_session')[0])).toBe(CHAT_SESSION_ID) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_ADDRESS)).toEqual(['run_session']) + expect(db.getRunMailboxOwnerIdsForHandle(CHAT_SESSION_ID)).toEqual([]) // The existing routing trigger matches the address by string equality, unchanged. const reply = db.insertMessage({ runId: 'run_session', from: 'term_worker', - to: CHAT_ACTOR, + to: CHAT_ADDRESS, subject: 'done', type: 'worker_done' }) expect(db.getMessageById(reply.id)?.to_handle).toBe('run:run_session') }) - it('remembers an actor bound by update, and again on reopen when the cache row is gone', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-actor-')) + it('remembers an Orca session id bound by update, and again on reopen when the cache row is gone', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-orca-session-')) tempRoots.push(root) const path = join(root, 'orchestration.db') db = new OrchestrationDb(path) @@ -84,16 +85,17 @@ describe('Run coordinator actor address', () => { .run() db.db .prepare( - `UPDATE runs SET coordinator_actor = ?, coordinator_actor_generation = consumer_generation + `UPDATE runs SET coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation WHERE id = ?` ) - .run(CHAT_ACTOR, 'run_unbound') - expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ACTOR]) + .run(CHAT_SESSION_ID, 'run_unbound') + expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ADDRESS]) db.db.prepare('DELETE FROM run_coordinator_handles WHERE run_id = ?').run('run_unbound') db.close() db = new OrchestrationDb(path) - expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ACTOR]) + expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ADDRESS]) }) it('keeps PTY coordinators remembered by handle alone', () => { @@ -109,11 +111,11 @@ describe('Run coordinator actor address', () => { coordinatorPaneKey: 'tab_second:77777777-7777-4777-8777-777777777777' }) - expect(db.getRunRaw(run.id)?.coordinator_actor).toBeNull() + expect(db.getRunRaw(run.id)?.coordinator_orca_session_id).toBeNull() expect(addressesFor(db, run.id)).toEqual(['term_first', 'term_second']) }) - it("never leaves a replaced structured coordinator's actor on the Run", () => { + it("never leaves a replaced structured coordinator's Orca session id on the Run", () => { db = new OrchestrationDb(':memory:') const handle = mintStructuredWorkerHandle() const pane = mintStructuredWorkerPaneKey(WORKER_SESSION_ID) @@ -131,8 +133,8 @@ describe('Run coordinator actor address', () => { coordinatorHandle: handle, coordinatorPaneKey: pane }) - backfillStructuredWorkerActors(db.db) - expect(db.getRunRaw(first.id)?.coordinator_actor).toBe(`session:${WORKER_SESSION_ID}`) + backfillStructuredWorkerOrcaSessionIds(db.db) + expect(db.getRunRaw(first.id)?.coordinator_orca_session_id).toBe(WORKER_SESSION_ID) // A second Run from the same pane unbinds the first. const second = db.createRun({ @@ -142,17 +144,19 @@ describe('Run coordinator actor address', () => { }) expect(db.getRunRaw(first.id)).toMatchObject({ coordinator_handle: null, - coordinator_actor: null + coordinator_orca_session_id: null }) - backfillStructuredWorkerActors(db.db) - expect(db.getRunRaw(second.id)?.coordinator_actor).toBe(`session:${WORKER_SESSION_ID}`) + backfillStructuredWorkerOrcaSessionIds(db.db) + expect(db.getRunRaw(second.id)?.coordinator_orca_session_id).toBe(WORKER_SESSION_ID) db.bindRun({ runId: second.id, coordinatorHandle: 'term_taker', coordinatorPaneKey: PTY_PANE }) expect(db.getRunRaw(second.id)).toMatchObject({ coordinator_handle: 'term_taker', - coordinator_actor: null + coordinator_orca_session_id: null }) // Neither Run ever became reachable at the worker's session address. - expect(db.getRunMailboxOwnerIdsForHandle(`session:${WORKER_SESSION_ID}`)).toEqual([]) + expect(db.getRunMailboxOwnerIdsForHandle(formatOrcaSessionAddress(WORKER_SESSION_ID))).toEqual( + [] + ) }) }) diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index bafb98f07e10..a9ad1b3c59e9 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -46,10 +46,10 @@ export type RunRow = { home_database: string coordinator_handle: string | null coordinator_pane_key: string | null - /** `session:` for a structured-session coordinator; NULL for a PTY one. See orchestration-actor. */ - coordinator_actor: string | null - /** The consumer_generation the actor was written at; see currentRunCoordinatorActor. */ - coordinator_actor_generation: number | null + /** Bare Orca session id the coordinator is addressed by (a `/clear`ed chat's lineage root); NULL for a PTY. */ + coordinator_orca_session_id: string | null + /** The consumer_generation the id was written at; see currentRunCoordinatorOrcaSessionId. */ + coordinator_orca_session_id_generation: number | null consumer_generation: number legacy: number created_at: string @@ -282,8 +282,8 @@ export type DispatchContextRow = { launch_token_hash: string | null assignee_handle: string | null assignee_pane_key: string | null - /** `session:` when the assignee is a structured session; NULL for a PTY. */ - assignee_actor: string | null + /** Bare Orca session id the assignee is addressed by (a `/clear`ed chat's lineage root); NULL for a PTY. */ + assignee_orca_session_id: string | null capability_hash: string | null process_incarnation: string | null capability_revoked_at: string | null @@ -293,8 +293,8 @@ export type DispatchContextRow = { /** Creator identity; equal to the assignee means a self-dispatch, which adds no nesting depth. */ creator_handle: string | null creator_pane_key: string | null - /** `session:` when the creator is a structured session; NULL for a PTY or Orca's loop. */ - creator_actor: string | null + /** Bare Orca session id the creator is addressed by (a `/clear`ed chat's lineage root); NULL for a PTY or Orca's loop. */ + creator_orca_session_id: string | null host_scope: string | null status: DispatchStatus failure_count: number 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 f8045859b6b5..49afdfe119a7 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 @@ -9,8 +9,8 @@ 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_actor: 'session:22222222-2222-4222-8222-222222222222', - coordinator_actor_generation: 3, + coordinator_orca_session_id: '22222222-2222-4222-8222-222222222222', + coordinator_orca_session_id_generation: 3, consumer_generation: 3, legacy: 0, created_at: '2026-09-04T18:53:07Z', @@ -32,8 +32,8 @@ describe('exposeRun', () => { ]) expect(exposed).not.toHaveProperty('home_database') expect(exposed).not.toHaveProperty('coordinator_pane_key') - expect(exposed).not.toHaveProperty('coordinator_actor') - expect(exposed).not.toHaveProperty('coordinator_actor_generation') + expect(exposed).not.toHaveProperty('coordinator_orca_session_id') + expect(exposed).not.toHaveProperty('coordinator_orca_session_id_generation') }) it('preserves every published column by value', () => { @@ -61,12 +61,12 @@ describe('exposeRun', () => { const exposed = exposeRun({ ...RUN_ROW, coordinator_pane_key: null, - coordinator_actor: null, - coordinator_actor_generation: null + coordinator_orca_session_id: null, + coordinator_orca_session_id_generation: null }) expect(exposed).not.toHaveProperty('coordinator_pane_key') - expect(exposed).not.toHaveProperty('coordinator_actor') - expect(exposed).not.toHaveProperty('coordinator_actor_generation') + expect(exposed).not.toHaveProperty('coordinator_orca_session_id') + expect(exposed).not.toHaveProperty('coordinator_orca_session_id_generation') }) }) diff --git a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts index 7ce98532e76e..b376d205874b 100644 --- a/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts +++ b/src/main/runtime/rpc/methods/orchestration/runs/run-receipt.ts @@ -1,12 +1,12 @@ import type { RunRow } from '../../../../orchestration/types' // Why: home_database and coordinator_pane_key are runtime routing state; no caller reads them. -// The coordinator actor stays off the wire until a reader needs it; publishing it is a wire change. +// The coordinator's Orca session id stays off the wire until a reader needs it; publishing it is a wire change. const INTERNAL_RUN_COLUMNS = [ 'home_database', 'coordinator_pane_key', - 'coordinator_actor', - 'coordinator_actor_generation' + 'coordinator_orca_session_id', + 'coordinator_orca_session_id_generation' ] as const export type RunReceipt = Omit diff --git a/src/shared/orca-session-address.test.ts b/src/shared/orca-session-address.test.ts new file mode 100644 index 000000000000..96b8a2268e5e --- /dev/null +++ b/src/shared/orca-session-address.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' +import { + ORCA_SESSION_ADDRESS_PREFIX, + formatOrcaSessionAddress, + isOrcaSessionId, + parseOrcaSessionAddress +} from './orca-session-address' + +const SESSION_ID = '0b7e4c2a-5f1d-4e8a-9c3b-2d6f8a1e4b70' +const ADDRESS = `session:${SESSION_ID}` + +describe('Orca session address', () => { + it('addresses an Orca session id as session: and parses the bare id back', () => { + 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) + }) + + it('reads only the addressed spelling when parsing an address', () => { + // A bare id is what the columns store, not an address. + expect(parseOrcaSessionAddress(SESSION_ID)).toBeNull() + expect(parseOrcaSessionAddress(null)).toBeNull() + expect(parseOrcaSessionAddress(undefined)).toBeNull() + expect(parseOrcaSessionAddress('')).toBeNull() + }) + + it.each([ + ['an unknown prefix', `pane:${SESSION_ID}`], + ['the Run mailbox namespace', 'run:run_123'], + ['the Dispatch mailbox namespace', 'dispatch:ctx_123'], + ['an empty prefix', `:${SESSION_ID}`], + ['an empty id', 'session:'], + ['an id with a separator', `session:${SESSION_ID}:extra`], + ['an id the session predicate rejects', 'session:short'], + ['a terminal handle', 'term_4f2c9a'] + ])('refuses %s', (_label, value) => { + expect(parseOrcaSessionAddress(value)).toBeNull() + }) + + it.each([ + ['a PTY terminal handle', 'term_4f2c9a1b-7d3e-4a5f-8b6c-9d0e1f2a3b4c'], + ['a short PTY terminal handle', 'term_4f2c9a'], + ['a structured-worker handle', 'structworker_4f2c9a1b-7d3e-4a5f-8b6c-9d0e1f2a3b4c'] + ])('never treats %s as an Orca session id', (_label, handle) => { + // Handles share the session-id charset, so the session-record predicate alone would accept them. + expect(isOrcaSessionId(handle)).toBe(false) + expect(parseOrcaSessionAddress(`session:${handle}`)).toBeNull() + }) + + it('validates an Orca session id with the session-record predicate', () => { + expect(isOrcaSessionId(SESSION_ID)).toBe(true) + expect(isOrcaSessionId('has space in it')).toBe(false) + expect(isOrcaSessionId('x'.repeat(129))).toBe(false) + }) +}) diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts new file mode 100644 index 000000000000..df8d61a0e7c4 --- /dev/null +++ b/src/shared/orca-session-address.ts @@ -0,0 +1,34 @@ +import { isAgentSessionId } from './agent-session-record' + +/** + * The Orca session id is the id Orca minted for a structured session (its session record id, the + * value of `ORCA_AGENT_SESSION_ID`), never the provider's own session id. Orchestration stores it bare. Mail addresses the session as `session:`, beside `run:` and + * `dispatch:`, and derives that spelling here rather than storing it. + * + * Where the session runs is not part of the id; it is read from the session record when needed. PTY + * agents have no Orca session id: a pane outlives the agent in it, so a pane-keyed id would be + * inherited by the pane's next occupant. + */ +export const ORCA_SESSION_ADDRESS_PREFIX = 'session:' + +// 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 { + return isAgentSessionId(id) && !TERMINAL_HANDLE_PREFIXES.some((prefix) => id.startsWith(prefix)) +} + +export function formatOrcaSessionAddress(orcaSessionId: string): string { + return `${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}` +} + +/** The bare Orca session id of a `session:` address; anything else reads as null. */ +export function parseOrcaSessionAddress(address: string | null | undefined): string | null { + if (!address?.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { + return null + } + const id = address.slice(ORCA_SESSION_ADDRESS_PREFIX.length) + return isOrcaSessionId(id) ? id : null +} diff --git a/src/shared/orchestration-actor.test.ts b/src/shared/orchestration-actor.test.ts deleted file mode 100644 index fab89f15f639..000000000000 --- a/src/shared/orchestration-actor.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - formatOrchestrationActor, - parseOrchestrationActor, - sessionOrchestrationActor -} from './orchestration-actor' - -const SESSION_ID = '0b7e4c2a-5f1d-4e8a-9c3b-2d6f8a1e4b70' -const ADDRESS = `session:${SESSION_ID}` - -describe('orchestration actor codec', () => { - it('spells a session actor as its mailbox address and parses it back', () => { - const actor = { kind: 'session', id: SESSION_ID } as const - - expect(formatOrchestrationActor(actor)).toBe(ADDRESS) - expect(parseOrchestrationActor(ADDRESS)).toEqual(actor) - expect(formatOrchestrationActor(parseOrchestrationActor(ADDRESS) ?? actor)).toBe(ADDRESS) - }) - - it('reads only the addressed spelling when parsing a stored value', () => { - // A bare id in a stored column or a recipient slot is not an actor; only input may be bare. - expect(parseOrchestrationActor(SESSION_ID)).toBeNull() - expect(parseOrchestrationActor(null)).toBeNull() - expect(parseOrchestrationActor(undefined)).toBeNull() - expect(parseOrchestrationActor('')).toBeNull() - }) - - it.each([ - ['an unknown kind', `pane:${SESSION_ID}`], - ['the Run mailbox namespace', 'run:run_123'], - ['the Dispatch mailbox namespace', 'dispatch:ctx_123'], - ['an empty kind', `:${SESSION_ID}`], - ['an empty id', 'session:'], - ['an id with a separator', `session:${SESSION_ID}:extra`], - ['an id the session predicate rejects', 'session:short'], - ['a terminal handle', 'term_4f2c9a'] - ])('refuses %s', (_label, value) => { - expect(parseOrchestrationActor(value)).toBeNull() - }) - - it.each([ - ['a PTY terminal handle', 'term_4f2c9a1b-7d3e-4a5f-8b6c-9d0e1f2a3b4c'], - ['a short PTY terminal handle', 'term_4f2c9a'], - ['a structured-worker handle', 'structworker_4f2c9a1b-7d3e-4a5f-8b6c-9d0e1f2a3b4c'] - ])('never turns %s into a session actor', (_label, handle) => { - // Handles share the session-id charset, so the predicate alone would accept them. - expect(sessionOrchestrationActor(handle)).toBeNull() - expect(parseOrchestrationActor(`session:${handle}`)).toBeNull() - }) - - it('validates a session id with the session-record predicate', () => { - expect(sessionOrchestrationActor(SESSION_ID)).toEqual({ kind: 'session', id: SESSION_ID }) - expect(sessionOrchestrationActor('has space in it')).toBeNull() - expect(sessionOrchestrationActor('x'.repeat(129))).toBeNull() - }) -}) diff --git a/src/shared/orchestration-actor.ts b/src/shared/orchestration-actor.ts deleted file mode 100644 index 75fc0bbe95d6..000000000000 --- a/src/shared/orchestration-actor.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { isAgentSessionId } from './agent-session-record' - -/** - * An orchestration party that is not a terminal, as `(kind, id)`. Stored in the `…_actor` columns - * and addressed in the mailbox namespace as `:`, beside `run:` and `dispatch:`, - * so the stored value and the address are one spelling. - * - * The only kind is a structured session, keyed by the id Orca minted for it — never the provider's - * id, which rotates on `/clear`. Where the session runs is not part of the key; it is read from the - * session record when needed. PTY agents have no actor: a pane outlives the agent in it, so a - * pane-keyed actor would be inherited by the pane's next occupant. - */ -// 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 to the codec by mistake -// must not become a durable session actor. -const TERMINAL_HANDLE_PREFIXES = ['term_', 'structworker_'] as const - -function isOrchestrationSessionId(id: string): boolean { - return isAgentSessionId(id) && !TERMINAL_HANDLE_PREFIXES.some((prefix) => id.startsWith(prefix)) -} - -const ACTOR_ID_PREDICATES = { - session: isOrchestrationSessionId -} as const satisfies Record boolean> - -export type OrchestrationActorKind = keyof typeof ACTOR_ID_PREDICATES - -export type OrchestrationActor = { kind: OrchestrationActorKind; id: string } - -function isOrchestrationActorKind(kind: string): kind is OrchestrationActorKind { - return Object.hasOwn(ACTOR_ID_PREDICATES, kind) -} - -export function formatOrchestrationActor(actor: OrchestrationActor): string { - return `${actor.kind}:${actor.id}` -} - -/** The stored and addressed spelling only. An unknown kind reads as null, never as a session. */ -export function parseOrchestrationActor( - value: string | null | undefined -): OrchestrationActor | null { - const separator = value?.indexOf(':') ?? -1 - if (!value || separator <= 0) { - return null - } - const kind = value.slice(0, separator) - const id = value.slice(separator + 1) - return isOrchestrationActorKind(kind) && ACTOR_ID_PREDICATES[kind](id) ? { kind, id } : null -} - -export function sessionOrchestrationActor(sessionId: string): OrchestrationActor | null { - return isOrchestrationSessionId(sessionId) ? { kind: 'session', id: sessionId } : null -} From 72552cd9b6c87ab767c586c38e80c07741637b2f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:04:05 -0700 Subject: [PATCH 10/11] fix(orchestration): remember every address a Run coordinator has, not the handle first The v42 coordinator triggers and the on-open refill stored one address, COALESCE(handle, session address), so a structured worker coordinator was remembered by its handle only. Remember each address the coordinator has, its handle and its current session address, each where present, so this cache follows the same rule as bindRun and no precedence is persisted. --- .../db/runs/run-coordinator-mail-routing.ts | 13 +- .../db/runs/run-coordinator-orca-session.ts | 7 +- .../db/schema/create-core-tables-sql.ts | 7 +- .../db/schema/create-graph-tables-sql.ts | 4 +- .../orchestration/db/schema/migrate-v42.ts | 29 +++-- ...tion-orca-session-column-migration.test.ts | 11 +- ...n-coordinator-orca-session-address.test.ts | 120 ++++++++++++++++-- src/main/runtime/orchestration/types.ts | 6 +- 8 files changed, 157 insertions(+), 40 deletions(-) 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 76821e989dae..07a6044d5aaa 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,5 @@ import type { OrchestrationDb } from '../orchestration-db' -import { currentRunCoordinatorAddressSql } from './run-coordinator-orca-session' +import { currentRunCoordinatorSessionAddressSql } from './run-coordinator-orca-session' export function rememberRunCoordinatorHandle( this: OrchestrationDb, @@ -13,14 +13,17 @@ export function rememberRunCoordinatorHandle( .run(runId, terminalHandle) } -const CURRENT_COORDINATOR_ADDRESS_SQL = currentRunCoordinatorAddressSql('runs') +const CURRENT_COORDINATOR_SESSION_ADDRESS_SQL = currentRunCoordinatorSessionAddressSql('runs') -// A handle-less structured-session coordinator is remembered by its session address (migrate-v42). +// Every address the coordinator has, its handle and its session address, as the migrate-v42 triggers. export function rememberCurrentRunCoordinatorHandles(this: OrchestrationDb): void { this.db.exec(` INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - SELECT id, ${CURRENT_COORDINATOR_ADDRESS_SQL} FROM runs - WHERE legacy = 0 AND ${CURRENT_COORDINATOR_ADDRESS_SQL} IS NOT NULL + SELECT id, coordinator_handle FROM runs + WHERE legacy = 0 AND coordinator_handle IS NOT NULL; + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + SELECT id, ${CURRENT_COORDINATOR_SESSION_ADDRESS_SQL} FROM runs + WHERE legacy = 0 AND ${CURRENT_COORDINATOR_SESSION_ADDRESS_SQL} IS NOT NULL; `) } 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 40146f63fd96..2ed9d9809a29 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 @@ -25,8 +25,7 @@ export function currentRunCoordinatorOrcaSessionIdSql(row: string): string { THEN ${row}.coordinator_orca_session_id END)` } -/** The mailbox address the coordinator is remembered by: its handle, else its session address. */ -export function currentRunCoordinatorAddressSql(row: string): string { - return `COALESCE(${row}.coordinator_handle, - '${ORCA_SESSION_ADDRESS_PREFIX}' || ${currentRunCoordinatorOrcaSessionIdSql(row)})` +/** 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)})` } diff --git a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts index b6400d98609e..ecc99241e35b 100644 --- a/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-core-tables-sql.ts @@ -8,7 +8,8 @@ CREATE TABLE IF NOT EXISTS runs ( home_database TEXT NOT NULL DEFAULT 'this_database', coordinator_handle TEXT, coordinator_pane_key TEXT, - -- Bare Orca session id the coordinator is addressed by (for a /clear'd chat, its lineage root's). + -- Bare Orca session id the coordinator is addressed by, when it has one (today only structured + -- sessions); for a /clear'd chat, its lineage root's. coordinator_orca_session_id TEXT, -- The consumer_generation coordinator_orca_session_id was written at; the id counts only while they -- are equal (run-coordinator-orca-session). So bump consumer_generation for a rebind or unbind only. @@ -60,8 +61,8 @@ CREATE TABLE IF NOT EXISTS run_coordinator_handles ( CREATE INDEX IF NOT EXISTS idx_run_coordinator_handles_handle ON run_coordinator_handles(terminal_handle, run_id); --- Handle-only on purpose; migrate-v42 replaces both triggers with a form that also remembers a --- handle-less coordinator by its session address. This SQL runs before migrate on every open, so it +-- Handle-only on purpose; migrate-v42 replaces both triggers with a form that also remembers the +-- coordinator's session address. This SQL runs before migrate on every open, so it -- must compile against a pre-v42 runs table: a trigger naming coordinator_orca_session_id there makes -- the next INSERT INTO runs fail to prepare mid-migration. CREATE TRIGGER IF NOT EXISTS trg_runs_remember_coordinator_insert diff --git a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts index 0768807931e6..e0a7a0bef2d4 100644 --- a/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts +++ b/src/main/runtime/orchestration/db/schema/create-graph-tables-sql.ts @@ -145,8 +145,8 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( launch_token_hash TEXT, assignee_handle TEXT, assignee_pane_key TEXT, - -- Bare Orca session id a structured-session party is addressed by (for a /clear'd chat, its - -- lineage root's), not its session: address; NULL for a PTY. + -- Bare Orca session id the agent is addressed by, when it has one (today only structured + -- sessions); for a /clear'd chat, its lineage root's. Not its session: address. assignee_orca_session_id TEXT, capability_hash TEXT, process_incarnation TEXT, diff --git a/src/main/runtime/orchestration/db/schema/migrate-v42.ts b/src/main/runtime/orchestration/db/schema/migrate-v42.ts index d56b279b7b97..2b69a5e60433 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v42.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v42.ts @@ -1,5 +1,5 @@ import type { OrchestrationDb } from '../orchestration-db' -import { currentRunCoordinatorAddressSql } from '../runs/run-coordinator-orca-session' +import { currentRunCoordinatorSessionAddressSql } from '../runs/run-coordinator-orca-session' const ORCA_SESSION_ID_COLUMNS = [ ['runs', 'coordinator_orca_session_id', 'TEXT'], @@ -8,12 +8,12 @@ const ORCA_SESSION_ID_COLUMNS = [ ['dispatch_contexts', 'creator_orca_session_id', 'TEXT'] ] as const -const NEW_COORDINATOR_ADDRESS_SQL = currentRunCoordinatorAddressSql('NEW') +const NEW_COORDINATOR_SESSION_ADDRESS_SQL = currentRunCoordinatorSessionAddressSql('NEW') /** - * Orca session id columns (bare ids, see orca-session-address): which structured session a Run's - * coordinator and a Dispatch's assignee and creator are when that party is one. PTY rows keep NULL - * and keep their handle and pane-key identity. Existing structured-worker rows get their id from + * Orca session id columns (bare ids, see orca-session-address) on a Run's coordinator and a + * Dispatch's assignee and creator: the Orca session id the agent is addressed by, when it has one + * (today only structured sessions). Existing structured-worker rows get their id from * `backfillStructuredWorkerOrcaSessionIds`, which runs after migrate on every open. A coordinator's * id carries the consumer generation it was written at and counts only at that generation. * The id is the one the agent is addressed by: for a `/clear`ed chat, its lineage root's, not the live one. @@ -39,8 +39,9 @@ export function migrateV42(this: OrchestrationDb, current: number): void { CREATE INDEX IF NOT EXISTS idx_dispatch_assignee_orca_session_id ON dispatch_contexts(assignee_orca_session_id) WHERE assignee_orca_session_id IS NOT NULL; `) - // A handle-less coordinator is remembered by its session address, `session:`, so every reader - // of this cache matches it by string equality, unchanged. This step owns the trigger form: the + // Every address the coordinator has is remembered, as bindRun remembers them: its handle and its + // session address, `session:`, so every reader of this cache matches either by string + // equality, unchanged, and neither address takes precedence. This step owns the trigger form: the // static createTables SQL must stay handle-only (see create-core-tables-sql), and CREATE TRIGGER IF // NOT EXISTS never replaces an existing database's triggers, so they are dropped and recreated by name. this.db.exec(` @@ -48,18 +49,24 @@ export function migrateV42(this: OrchestrationDb, current: number): void { DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_update; CREATE TRIGGER trg_runs_remember_coordinator_insert AFTER INSERT ON runs - WHEN NEW.legacy = 0 AND ${NEW_COORDINATOR_ADDRESS_SQL} IS NOT NULL + WHEN NEW.legacy = 0 BEGIN INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - VALUES (NEW.id, ${NEW_COORDINATOR_ADDRESS_SQL}); + SELECT NEW.id, NEW.coordinator_handle WHERE NEW.coordinator_handle IS NOT NULL; + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + SELECT NEW.id, ${NEW_COORDINATOR_SESSION_ADDRESS_SQL} + WHERE ${NEW_COORDINATOR_SESSION_ADDRESS_SQL} IS NOT NULL; END; CREATE TRIGGER trg_runs_remember_coordinator_update AFTER UPDATE OF coordinator_handle, coordinator_orca_session_id, coordinator_orca_session_id_generation ON runs - WHEN NEW.legacy = 0 AND ${NEW_COORDINATOR_ADDRESS_SQL} IS NOT NULL + WHEN NEW.legacy = 0 BEGIN INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - VALUES (NEW.id, ${NEW_COORDINATOR_ADDRESS_SQL}); + SELECT NEW.id, NEW.coordinator_handle WHERE NEW.coordinator_handle IS NOT NULL; + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + SELECT NEW.id, ${NEW_COORDINATOR_SESSION_ADDRESS_SQL} + WHERE ${NEW_COORDINATOR_SESSION_ADDRESS_SQL} IS NOT NULL; END; `) } 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 e0a65118112a..69186334828b 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 @@ -228,9 +228,14 @@ describe('orchestration Orca session id column migration', () => { ).toEqual([{ id: rows.structuredDispatchId }]) expect(db.getRunRaw(rows.ptyRunId)?.coordinator_orca_session_id).toBeNull() expect(db.getRunRaw(rows.structuredRunId)?.coordinator_orca_session_id).toBe(SESSION_ID) - // A handle-bearing coordinator stays remembered by its handle alone, as before v42. + // Every address a coordinator has: the PTY one its handle, the structured worker its handle + // and the session address its backfilled id gives it. expect(coordinatorAddresses(db.db, [rows.ptyRunId, rows.structuredRunId])).toEqual( - [`${rows.ptyRunId} term_coord`, `${rows.structuredRunId} ${rows.workerHandle}`].sort() + [ + `${rows.ptyRunId} term_coord`, + `${rows.structuredRunId} ${rows.workerHandle}`, + `${rows.structuredRunId} ${formatOrcaSessionAddress(SESSION_ID)}` + ].sort() ) // CREATE TRIGGER IF NOT EXISTS alone would have kept the handle-only form here. for (const sql of coordinatorTriggerSql(db.db)) { @@ -378,9 +383,11 @@ describe('orchestration Orca session id column migration', () => { expect(readOrcaSessionId.get(rows.structuredRunId)).toEqual({ coordinator_orca_session_id: SESSION_ID }) + // The session address was remembered by the v42 open, before v41's rebind made the id stale. expect(coordinatorAddresses(v41, ['run_v41', rows.structuredRunId])).toEqual( [ `${rows.structuredRunId} ${rows.workerHandle}`, + `${rows.structuredRunId} ${formatOrcaSessionAddress(SESSION_ID)}`, `${rows.structuredRunId} term_taker`, 'run_v41 term_v41' ].sort() 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 c37b35f4f5df..f93e1e4a4b5f 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 @@ -17,6 +17,7 @@ import { backfillStructuredWorkerOrcaSessionIds } from './db/schema/structured-w const CHAT_SESSION_ID = '3a5c7e9b-1d4f-4a6c-8b0e-2f4a6c8e0b14' const CHAT_ADDRESS = formatOrcaSessionAddress(CHAT_SESSION_ID) const WORKER_SESSION_ID = '4b6d8f0c-2e5a-4b7d-9c1f-3a5b7d9f1c25' +const WORKER_ADDRESS = formatOrcaSessionAddress(WORKER_SESSION_ID) const PTY_PANE = 'tab_pty:66666666-6666-4666-8666-666666666666' function addressesFor(db: OrchestrationDb, runId: string): string[] { @@ -27,6 +28,19 @@ function addressesFor(db: OrchestrationDb, runId: string): string[] { .sort() } +function tempDbPath(tempRoots: string[]): string { + const root = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-orca-session-')) + tempRoots.push(root) + return join(root, 'orchestration.db') +} + +/** Drops the Run's remembered addresses and reopens, so only the on-open refill can write them back. */ +function refillAfterReopen(db: OrchestrationDb, path: string, runId: string): OrchestrationDb { + db.db.prepare('DELETE FROM run_coordinator_handles WHERE run_id = ?').run(runId) + db.close() + return new OrchestrationDb(path) +} + /** A handle-less coordinator row; no writer records one until the caller resolver lands. */ function insertSessionCoordinatedRun(db: OrchestrationDb, runId: string): void { db.db @@ -73,9 +87,7 @@ describe('Run coordinator Orca session address', () => { }) it('remembers an Orca session id bound by update, and again on reopen when the cache row is gone', () => { - const root = mkdtempSync(join(tmpdir(), 'orca-run-coordinator-orca-session-')) - tempRoots.push(root) - const path = join(root, 'orchestration.db') + const path = tempDbPath(tempRoots) db = new OrchestrationDb(path) db.db .prepare( @@ -91,13 +103,101 @@ describe('Run coordinator Orca session address', () => { ) .run(CHAT_SESSION_ID, 'run_unbound') expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ADDRESS]) - db.db.prepare('DELETE FROM run_coordinator_handles WHERE run_id = ?').run('run_unbound') - db.close() - db = new OrchestrationDb(path) + db = refillAfterReopen(db, path, 'run_unbound') expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ADDRESS]) }) + it('remembers a PTY coordinator written by insert, update and refill by exactly its handle', () => { + const path = tempDbPath(tempRoots) + db = new OrchestrationDb(path) + db.db + .prepare( + `INSERT INTO runs (id, objective, coordinator_handle, consumer_generation, legacy) + VALUES ('run_pty', 'pty', 'term_first', 1, 0)` + ) + .run() + expect(addressesFor(db, 'run_pty')).toEqual(['term_first']) + db.db + .prepare( + `UPDATE runs SET coordinator_handle = 'term_second', + consumer_generation = consumer_generation + 1 WHERE id = 'run_pty'` + ) + .run() + expect(addressesFor(db, 'run_pty')).toEqual(['term_first', 'term_second']) + + db = refillAfterReopen(db, path, 'run_pty') + expect(addressesFor(db, 'run_pty')).toEqual(['term_second']) + }) + + it('remembers a structured-worker coordinator by its handle and its session address', () => { + const path = tempDbPath(tempRoots) + db = new OrchestrationDb(path) + const handle = mintStructuredWorkerHandle() + db.db + .prepare( + `INSERT INTO runs ( + id, objective, coordinator_handle, coordinator_orca_session_id, + coordinator_orca_session_id_generation, consumer_generation, legacy + ) VALUES ('run_inserted', 'structured worker', ?, ?, 1, 1, 0)` + ) + .run(handle, WORKER_SESSION_ID) + expect(addressesFor(db, 'run_inserted')).toEqual([WORKER_ADDRESS, handle].sort()) + + db.db + .prepare( + `INSERT INTO runs (id, objective, coordinator_handle, consumer_generation, legacy) + VALUES ('run_updated', 'id recorded later', ?, 1, 0)` + ) + .run(handle) + db.db + .prepare( + `UPDATE runs SET coordinator_orca_session_id = ?, + coordinator_orca_session_id_generation = consumer_generation + WHERE id = 'run_updated'` + ) + .run(WORKER_SESSION_ID) + expect(addressesFor(db, 'run_updated')).toEqual([WORKER_ADDRESS, handle].sort()) + + db = refillAfterReopen(db, path, 'run_updated') + expect(addressesFor(db, 'run_updated')).toEqual([WORKER_ADDRESS, handle].sort()) + expect(db.getRunMailboxOwnerIdsForHandle(WORKER_ADDRESS)).toEqual( + db.getRunMailboxOwnerIdsForHandle(handle) + ) + }) + + it('adds no session address for an Orca session id at a stale generation', () => { + const path = tempDbPath(tempRoots) + db = new OrchestrationDb(path) + db.db + .prepare( + `INSERT INTO runs ( + id, objective, coordinator_handle, coordinator_orca_session_id, + coordinator_orca_session_id_generation, consumer_generation, legacy + ) VALUES ('run_stale_insert', 'stale id', 'term_stale', ?, 1, 2, 0)` + ) + .run(WORKER_SESSION_ID) + expect(addressesFor(db, 'run_stale_insert')).toEqual(['term_stale']) + + db.db + .prepare( + `INSERT INTO runs (id, objective, consumer_generation, legacy) + VALUES ('run_stale_update', 'written stale', 2, 0)` + ) + .run() + db.db + .prepare( + `UPDATE runs SET coordinator_orca_session_id = ?, coordinator_orca_session_id_generation = 1 + WHERE id = 'run_stale_update'` + ) + .run(WORKER_SESSION_ID) + expect(addressesFor(db, 'run_stale_update')).toEqual([]) + + db = refillAfterReopen(db, path, 'run_stale_insert') + expect(addressesFor(db, 'run_stale_insert')).toEqual(['term_stale']) + expect(db.getRunMailboxOwnerIdsForHandle(WORKER_ADDRESS)).toEqual([]) + }) + it('keeps PTY coordinators remembered by handle alone', () => { db = new OrchestrationDb(':memory:') const run = db.createRun({ @@ -154,9 +254,9 @@ describe('Run coordinator Orca session address', () => { coordinator_handle: 'term_taker', coordinator_orca_session_id: null }) - // Neither Run ever became reachable at the worker's session address. - expect(db.getRunMailboxOwnerIdsForHandle(formatOrcaSessionAddress(WORKER_SESSION_ID))).toEqual( - [] - ) + // A remembered address is never forgotten, so the worker's session address reaches exactly the + // Runs its handle does. + expect(db.getRunMailboxOwnerIdsForHandle(WORKER_ADDRESS)).toEqual([first.id, second.id].sort()) + expect(db.getRunMailboxOwnerIdsForHandle(handle)).toEqual([first.id, second.id].sort()) }) }) diff --git a/src/main/runtime/orchestration/types.ts b/src/main/runtime/orchestration/types.ts index a9ad1b3c59e9..d00ea480c7dd 100644 --- a/src/main/runtime/orchestration/types.ts +++ b/src/main/runtime/orchestration/types.ts @@ -46,7 +46,7 @@ export type RunRow = { home_database: string coordinator_handle: string | null coordinator_pane_key: string | null - /** Bare Orca session id the coordinator is addressed by (a `/clear`ed chat's lineage root); NULL for a PTY. */ + /** 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 /** The consumer_generation the id was written at; see currentRunCoordinatorOrcaSessionId. */ coordinator_orca_session_id_generation: number | null @@ -282,7 +282,7 @@ export type DispatchContextRow = { launch_token_hash: string | null assignee_handle: string | null assignee_pane_key: string | null - /** Bare Orca session id the assignee is addressed by (a `/clear`ed chat's lineage root); NULL for a PTY. */ + /** 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 capability_hash: string | null process_incarnation: string | null @@ -293,7 +293,7 @@ export type DispatchContextRow = { /** Creator identity; equal to the assignee means a self-dispatch, which adds no nesting depth. */ creator_handle: string | null creator_pane_key: string | null - /** Bare Orca session id the creator is addressed by (a `/clear`ed chat's lineage root); NULL for a PTY or Orca's loop. */ + /** 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 host_scope: string | null status: DispatchStatus From 0bfebd79dc795b902e386ceb30808b532f4ad871 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:34:45 -0700 Subject: [PATCH 11/11] docs(orchestration): define the Orca session id without a variable this change does not add The shared codec's comment named ORCA_AGENT_SESSION_ID, which nothing in this change defines, and ran one line past the wrap. It now says the stored id is the one the agent is addressed by (a /clear'd chat's lineage root), as the column comments do, and that PTY agents have none today rather than never. migrate-v42's note stated the lineage rule twice; it is folded into one sentence. --- .../runtime/orchestration/db/schema/migrate-v42.ts | 8 ++++---- src/shared/orca-session-address.ts | 12 +++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/runtime/orchestration/db/schema/migrate-v42.ts b/src/main/runtime/orchestration/db/schema/migrate-v42.ts index 2b69a5e60433..e8d55037abc8 100644 --- a/src/main/runtime/orchestration/db/schema/migrate-v42.ts +++ b/src/main/runtime/orchestration/db/schema/migrate-v42.ts @@ -13,10 +13,10 @@ const NEW_COORDINATOR_SESSION_ADDRESS_SQL = currentRunCoordinatorSessionAddressS /** * Orca session id columns (bare ids, see orca-session-address) on a Run's coordinator and a * Dispatch's assignee and creator: the Orca session id the agent is addressed by, when it has one - * (today only structured sessions). Existing structured-worker rows get their id from - * `backfillStructuredWorkerOrcaSessionIds`, which runs after migrate on every open. A coordinator's - * id carries the consumer generation it was written at and counts only at that generation. - * The id is the one the agent is addressed by: for a `/clear`ed chat, its lineage root's, not the live one. + * (today only structured sessions); for a `/clear`ed chat, its lineage root's, not the live one. + * Existing structured-worker rows get their id from `backfillStructuredWorkerOrcaSessionIds`, which + * runs after migrate on every open. A coordinator's id carries the consumer generation it was + * written at and counts only at that generation. * * Dev databases stamped v42 by earlier builds hold `*_principal` or `*_actor` columns instead. They * are unsupported: the version-skew probe finds a column missing and replays the chain, which adds diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index df8d61a0e7c4..f2ccd4fe00cc 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -1,13 +1,15 @@ import { isAgentSessionId } from './agent-session-record' /** - * The Orca session id is the id Orca minted for a structured session (its session record id, the - * value of `ORCA_AGENT_SESSION_ID`), never the provider's own session id. Orchestration stores it bare. Mail addresses the session as `session:`, beside `run:` and - * `dispatch:`, and derives that spelling here rather than storing it. + * The Orca session id is the id Orca minted for a structured session (its session record id), never + * the provider's own session id. Orchestration stores, bare, the one the agent is addressed by: for + * a `/clear`ed chat, its lineage root's, not the live session's. Mail addresses the session as + * `session:`, beside `run:` and `dispatch:`, and derives that spelling here rather than + * storing it. * * Where the session runs is not part of the id; it is read from the session record when needed. PTY - * agents have no Orca session id: a pane outlives the agent in it, so a pane-keyed id would be - * inherited by the pane's next occupant. + * agents have none today, and never a pane-keyed one: a pane outlives the agent in it, so such an id + * would be inherited by the pane's next occupant. */ export const ORCA_SESSION_ADDRESS_PREFIX = 'session:'