From f03ba50f4157415c3367c4bf9d2a742b3ce43502 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 10 Sep 2026 16:09:07 -0700 Subject: [PATCH 1/4] add shared orchestration principal module with pane-key classification principalFromPaneKey is the single owner of both dual-write rules: NULL pane key means NULL principal, and a structured worker's minted pane key classifies to session: via parsePaneKey plus the new inverse structuredAgentSessionIdFromTabId. The tab-id pair moves to its own shared module (single source for the prefix) because the projection file sits at the max-lines cap; the projection re-exports both names. --- src/shared/orchestration-principal.test.ts | 59 +++++++++++++++++++ src/shared/orchestration-principal.ts | 58 ++++++++++++++++++ .../structured-agent-session-projection.ts | 7 ++- src/shared/structured-agent-session-tab-id.ts | 15 +++++ 4 files changed, 136 insertions(+), 3 deletions(-) create mode 100644 src/shared/orchestration-principal.test.ts create mode 100644 src/shared/orchestration-principal.ts create mode 100644 src/shared/structured-agent-session-tab-id.ts diff --git a/src/shared/orchestration-principal.test.ts b/src/shared/orchestration-principal.test.ts new file mode 100644 index 000000000000..a9956baab9e8 --- /dev/null +++ b/src/shared/orchestration-principal.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + formatOrchestrationPrincipal, + parseOrchestrationPrincipal, + principalFromPaneKey +} from './orchestration-principal' +import { structuredAgentSessionTabId } from './structured-agent-session-tab-id' + +const LEAF = '6f9e2d4a-1b3c-4d5e-8f7a-9b8c7d6e5f4a' + +describe('orchestration principal', () => { + it('round-trips both kinds through format then parse', () => { + const pane = { kind: 'pane', paneKey: `tab_a:${LEAF}` } as const + const session = { kind: 'session', sessionId: 'sess_123' } as const + expect(parseOrchestrationPrincipal(formatOrchestrationPrincipal(pane))).toEqual(pane) + expect(parseOrchestrationPrincipal(formatOrchestrationPrincipal(session))).toEqual(session) + }) + + it('keeps the payload intact past the first colon of a real pane key', () => { + expect(parseOrchestrationPrincipal(`pane:tab_a:${LEAF}`)).toEqual({ + kind: 'pane', + paneKey: `tab_a:${LEAF}` + }) + }) + + it('returns null for empty payload, unknown tag, no colon, and empty string', () => { + expect(parseOrchestrationPrincipal('pane:')).toBeNull() + expect(parseOrchestrationPrincipal('session:')).toBeNull() + expect(parseOrchestrationPrincipal('lease:x')).toBeNull() + expect(parseOrchestrationPrincipal('no-colon')).toBeNull() + expect(parseOrchestrationPrincipal('')).toBeNull() + }) + + it('classifies a NULL pane key to a NULL principal', () => { + expect(principalFromPaneKey(null)).toBeNull() + expect(principalFromPaneKey(undefined)).toBeNull() + }) + + it('classifies an ordinary pane key to a pane principal', () => { + expect(principalFromPaneKey(`tab_a:${LEAF}`)).toBe(`pane:tab_a:${LEAF}`) + }) + + it('classifies a structured worker pane key to its session principal', () => { + const paneKey = `${structuredAgentSessionTabId('sess_123')}:${LEAF}` + expect(principalFromPaneKey(paneKey)).toBe('session:sess_123') + }) + + it('classifies an unparseable pane key as a pane principal, not null', () => { + // Legacy/free-form keys stay addressable as opaque pane identity. + expect(principalFromPaneKey('tab_worker:leaf_worker')).toBe('pane:tab_worker:leaf_worker') + }) + + it('never derives a session principal without a real terminal leaf', () => { + // A fabricated structured-looking key with a non-UUID leaf does not parse as a pane key, so it + // stays a pane principal — classification never rewards a guessed session id by itself. + const fabricated = `${structuredAgentSessionTabId('sess_123')}:anything` + expect(principalFromPaneKey(fabricated)).toBe(`pane:${fabricated}`) + }) +}) diff --git a/src/shared/orchestration-principal.ts b/src/shared/orchestration-principal.ts new file mode 100644 index 000000000000..8aa6a61ae7ca --- /dev/null +++ b/src/shared/orchestration-principal.ts @@ -0,0 +1,58 @@ +import { parsePaneKey } from './stable-pane-id' +import { structuredAgentSessionIdFromTabId } from './structured-agent-session-tab-id' + +/** Serialized actor identity persisted on orchestration rows; write-only until the resolver PRs. */ +export type OrchestrationPrincipal = + | { kind: 'pane'; paneKey: string } + | { kind: 'session'; sessionId: string } + +export function formatOrchestrationPrincipal(principal: OrchestrationPrincipal): string { + return principal.kind === 'pane' ? `pane:${principal.paneKey}` : `session:${principal.sessionId}` +} + +/** + * Split at the FIRST `:` only — pane keys themselves contain a `:`, so the payload is everything + * after the first colon, verbatim. Unknown tag → null, not throw: a future kind written by a newer + * binary must degrade, not crash (remote-wire-compatibility posture applied to the DB). + */ +export function parseOrchestrationPrincipal(value: string): OrchestrationPrincipal | null { + const first = value.indexOf(':') + if (first <= 0 || first === value.length - 1) { + return null + } + const tag = value.slice(0, first) + const payload = value.slice(first + 1) + if (tag === 'pane') { + return { kind: 'pane', paneKey: payload } + } + if (tag === 'session') { + return { kind: 'session', sessionId: payload } + } + return null +} + +/** + * The dual-write/backfill rule: NULL pane key ⇒ NULL principal, and a structured worker's pane key + * (`structured-agent-session-:`) classifies to `session:`; any + * other non-NULL pane key is `pane:`. + * + * SECURITY BOUNDARY: deriving a `session:` principal from a pane key is safe here and nowhere + * else — in the backfill it is a one-time server-side derivation over durable rows the host itself + * wrote, and at dual-write time it applies only to pane keys the host minted or attested. At + * REQUEST time the same transformation is a vulnerability: session ids are embedded in tab ids in + * plain text, so a caller who learns one can fabricate `structured-agent-session-:` + * — the random leaf UUID is the only real credential (src/main/runtime/structured-worker-identity.ts). + * No RPC boundary may ever derive a session principal from a caller-supplied pane key; credentials + * resolve to principals at the boundary, and the resolver PR owns that request-time invariant. The + * backfill rule is NOT a general equivalence rule. + */ +export function principalFromPaneKey(paneKey: string | null | undefined): string | null { + if (!paneKey) { + return null + } + const parsed = parsePaneKey(paneKey) + const sessionId = parsed ? structuredAgentSessionIdFromTabId(parsed.tabId) : null + return sessionId + ? formatOrchestrationPrincipal({ kind: 'session', sessionId }) + : formatOrchestrationPrincipal({ kind: 'pane', paneKey }) +} diff --git a/src/shared/structured-agent-session-projection.ts b/src/shared/structured-agent-session-projection.ts index 14c02756a60f..46f50f1f2789 100644 --- a/src/shared/structured-agent-session-projection.ts +++ b/src/shared/structured-agent-session-projection.ts @@ -209,9 +209,10 @@ export function hasUnansweredStructuredAgentSessionDispatch( export type StructuredAgentSessionProjectedStatus = 'working' | 'attention' | 'idle' -export function structuredAgentSessionTabId(sessionId: string): string { - return `structured-agent-session-${sessionId}` -} +export { + structuredAgentSessionIdFromTabId, + structuredAgentSessionTabId +} from './structured-agent-session-tab-id' export function projectStructuredAgentSessionStatus( items: readonly AgentJournalRenderItem[], diff --git a/src/shared/structured-agent-session-tab-id.ts b/src/shared/structured-agent-session-tab-id.ts new file mode 100644 index 000000000000..586eac1771b4 --- /dev/null +++ b/src/shared/structured-agent-session-tab-id.ts @@ -0,0 +1,15 @@ +// Single source for the structured-session tab-id shape; the inverse exists for server-side +// principal classification only (see orchestration-principal.ts for the security boundary). +const STRUCTURED_AGENT_SESSION_TAB_ID_PREFIX = 'structured-agent-session-' + +export function structuredAgentSessionTabId(sessionId: string): string { + return `${STRUCTURED_AGENT_SESSION_TAB_ID_PREFIX}${sessionId}` +} + +/** Inverse of structuredAgentSessionTabId; null when the tab id is not a structured-session tab. */ +export function structuredAgentSessionIdFromTabId(tabId: string): string | null { + const sessionId = tabId.startsWith(STRUCTURED_AGENT_SESSION_TAB_ID_PREFIX) + ? tabId.slice(STRUCTURED_AGENT_SESSION_TAB_ID_PREFIX.length) + : '' + return sessionId.length > 0 ? sessionId : null +} From 1b4fa0ae055e797eb09055a70230b824279e8be9 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 10 Sep 2026 16:09:24 -0700 Subject: [PATCH 2/4] add v41 principal columns with classifying backfill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration v41 adds the four nullable principal columns and owns the coordinator-cache trigger form: the static createTables SQL keeps the handle-only text because it must stay compilable against a pre-v41 runs table — a database stamped before the cache existed (< v30) would otherwise get triggers naming coordinator_principal before the ALTER runs, and the v40 backfill's INSERT INTO runs dies at prepare. The v41 triggers cache COALESCE(coordinator_handle, coordinator_principal): terminal_handle is a mailbox-address column (messages.to_handle already carries run:), so a handle-less session coordinator's row matches every string-equality reader with zero read-side change, and the cache keeps its shape. The backfill classifies via principalFromPaneKey (a structured worker's row gets session:, session values are fenced from repair) and reruns on every open, v40-style, to repair rows a rolled-back binary writes after user_version is already 41. coordinator_principal joins the internal receipt columns so it never ships on the wire. --- .../orchestration/db/contract-constants.ts | 4 +- .../orchestration/db/orchestration-db.ts | 2 + .../orchestration/db/row-column-lists.ts | 3 ++ .../db/schema/create-core-tables-sql.ts | 5 ++ .../db/schema/create-graph-tables-sql.ts | 2 + .../orchestration/db/schema/migrate-v41.ts | 54 +++++++++++++++++++ .../orchestration/db/schema/migrate.ts | 2 + .../db/schema/principal-column-backfill.ts | 50 +++++++++++++++++ .../orchestration-schema-version-skew.ts | 6 ++- src/main/runtime/orchestration/types.ts | 6 +++ .../worker-terminal-ownership.ts | 2 + .../orchestration/runs/run-receipt.test.ts | 2 + .../methods/orchestration/runs/run-receipt.ts | 9 +++- 13 files changed, 142 insertions(+), 5 deletions(-) create mode 100644 src/main/runtime/orchestration/db/schema/migrate-v41.ts create mode 100644 src/main/runtime/orchestration/db/schema/principal-column-backfill.ts diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index 56c2e2d542ff..8be91fd70bbe 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -16,5 +16,5 @@ export function federatedStubHomeRunId(dispatchId: string): string { export const LEGACY_CONTRACT_VERSION = 0 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. -export const SCHEMA_VERSION = 40 +// 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, v40 federated stub home Runs, v41 actor principal columns. +export const SCHEMA_VERSION = 41 diff --git a/src/main/runtime/orchestration/db/orchestration-db.ts b/src/main/runtime/orchestration/db/orchestration-db.ts index 1ce52e96c4a5..62b3cb31fa34 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 { backfillPrincipalColumns } from './schema/principal-column-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) + backfillPrincipalColumns(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..dde6cf1cbcce 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_principal', 'consumer_generation', 'legacy', 'created_at', @@ -45,6 +46,7 @@ export const DISPATCH_CONTEXT_COLUMNS = [ 'launch_token_hash', 'assignee_handle', 'assignee_pane_key', + 'assignee_principal', '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_principal', 'host_scope', 'status', 'failure_count', 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..f8c20b43a963 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_principal 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); +-- The principal-aware (COALESCE) trigger form is owned by migrate-v41, which every DB passes +-- through: the static SQL below must stay compilable against a pre-v41 runs table, where naming +-- NEW.coordinator_principal would break the first runs INSERT of the migration chain. 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 @@ -159,6 +163,7 @@ CREATE TABLE IF NOT EXISTS worker_terminal_resources ( worktree_id TEXT, terminal_handle TEXT NOT NULL, pane_key TEXT, + principal TEXT, process_incarnation TEXT, endpoint_id TEXT, endpoint_incarnation TEXT, 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..84b103f73b76 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,7 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( launch_token_hash TEXT, assignee_handle TEXT, assignee_pane_key TEXT, + assignee_principal TEXT, capability_hash TEXT, process_incarnation TEXT, capability_revoked_at TEXT, @@ -155,6 +156,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_principal 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-v41.ts b/src/main/runtime/orchestration/db/schema/migrate-v41.ts new file mode 100644 index 000000000000..e38e24a205dc --- /dev/null +++ b/src/main/runtime/orchestration/db/schema/migrate-v41.ts @@ -0,0 +1,54 @@ +import type { OrchestrationDb } from '../orchestration-db' +import { backfillPrincipalColumns } from './principal-column-backfill' + +const PRINCIPAL_COLUMNS = [ + ['runs', 'coordinator_principal'], + ['dispatch_contexts', 'assignee_principal'], + ['dispatch_contexts', 'creator_principal'], + ['worker_terminal_resources', 'principal'] +] as const + +/** + * Actor principal columns: serialized `OrchestrationPrincipal` alongside every handle/pane-key + * identity column. Write-only in this version — no read path names them yet. The coordinator + * mailbox-address cache keeps its shape; a handle-less session coordinator is cached by writing + * its principal into `terminal_handle`, which is a mailbox-address column, so existing readers + * match it by plain string equality. + */ +export function migrateV41(this: OrchestrationDb, current: number): void { + if (current >= 41) { + return + } + // hasColumn guards are mandatory: createTables runs before migrate on every open, so a fresh + // database already has the columns and an unguarded ALTER would throw duplicate-column. + for (const [table, column] of PRINCIPAL_COLUMNS) { + if (!this.hasColumn(table, column)) { + this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} TEXT`) + } + } + // Sole owner of the COALESCE trigger form. The static createTables SQL keeps the handle-only + // form because it must stay compilable against a pre-v41 runs table: a database old enough to + // predate the cache (stamped < 30) would otherwise get triggers naming coordinator_principal + // before this ALTER runs, and the v40 backfill's INSERT INTO runs dies at prepare. Drop by + // name — CREATE TRIGGER IF NOT EXISTS never replaces an existing DB's old-predicate triggers. + // Idempotent under replay, and safe on a fresh DB (recreates the just-created form). + 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 (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; + `) + backfillPrincipalColumns(this.db) +} diff --git a/src/main/runtime/orchestration/db/schema/migrate.ts b/src/main/runtime/orchestration/db/schema/migrate.ts index 582dedf4752c..33362e595a24 100644 --- a/src/main/runtime/orchestration/db/schema/migrate.ts +++ b/src/main/runtime/orchestration/db/schema/migrate.ts @@ -11,6 +11,7 @@ import { migrateV37 } from './migrate-v37' import { migrateV38 } from './migrate-v38' import { migrateV39 } from './migrate-v39' import { migrateV40 } from './migrate-v40' +import { migrateV41 } from './migrate-v41' // 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 { @@ -32,6 +33,7 @@ export function migrate(this: OrchestrationDb): void { migrateV38.call(this, current) migrateV39.call(this, current) migrateV40.call(this, current) + migrateV41.call(this, current) this.createMailboxDeliveryIndexesIfPossible() this.db.pragma(`user_version = ${SCHEMA_VERSION}`) this.db.exec('COMMIT') diff --git a/src/main/runtime/orchestration/db/schema/principal-column-backfill.ts b/src/main/runtime/orchestration/db/schema/principal-column-backfill.ts new file mode 100644 index 000000000000..109d0db58050 --- /dev/null +++ b/src/main/runtime/orchestration/db/schema/principal-column-backfill.ts @@ -0,0 +1,50 @@ +import type Database from '../../../../sqlite/sync-database' +import { principalFromPaneKey } from '../../../../../shared/orchestration-principal' + +const PRINCIPAL_COLUMN_TUPLES = [ + { table: 'runs', paneColumn: 'coordinator_pane_key', principalColumn: 'coordinator_principal' }, + { + table: 'dispatch_contexts', + paneColumn: 'assignee_pane_key', + principalColumn: 'assignee_principal' + }, + { + table: 'dispatch_contexts', + paneColumn: 'creator_pane_key', + principalColumn: 'creator_principal' + }, + { table: 'worker_terminal_resources', paneColumn: 'pane_key', principalColumn: 'principal' } +] as const + +/** + * Reconciles principal columns from their pane-key columns — the v40 every-open pattern: a binary + * rolled back past v41 keeps writing after user_version is already 41, inserting rows with NULL + * principals and, worse, re-pointing pane keys without touching the principal, leaving stale + * `pane:` values. Runs from migrate-v41 and from every open; idempotent by construction (a second + * pass classifies to the same values and matches zero mismatches). + * + * The repair CLASSIFIES via principalFromPaneKey, never blindly copies, and rows whose principal + * is `session:...` are fenced out entirely: a session row's pane columns hold the minted fake key + * by design, so a copy-based reconcile would re-assert the wrong `pane:` value on every open. + * Rows with a NULL pane key stay NULL — inventing a principal from a bare handle would mint + * identity from a credential. + */ +export function backfillPrincipalColumns(db: Database.Database): void { + for (const { table, paneColumn, principalColumn } of PRINCIPAL_COLUMN_TUPLES) { + // Zero rows on a healthy database, so the steady-state open cost is four cheap scans. + const candidates = db + .prepare( + `SELECT id, ${paneColumn} AS pane_key, ${principalColumn} AS principal FROM ${table} + WHERE (${principalColumn} IS NULL AND ${paneColumn} IS NOT NULL) + OR ${principalColumn} LIKE 'pane:%'` + ) + .all() as { id: string; pane_key: string | null; principal: string | null }[] + const repair = db.prepare(`UPDATE ${table} SET ${principalColumn} = ? WHERE id = ?`) + for (const row of candidates) { + const expected = principalFromPaneKey(row.pane_key) + if (row.principal !== expected) { + repair.run(expected, row.id) + } + } + } +} diff --git a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts index 85e0a78b3ae4..c86457fc8051 100644 --- a/src/main/runtime/orchestration/orchestration-schema-version-skew.ts +++ b/src/main/runtime/orchestration/orchestration-schema-version-skew.ts @@ -43,7 +43,11 @@ 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: 41, table: 'runs', column: 'coordinator_principal' }, + { version: 41, table: 'dispatch_contexts', column: 'assignee_principal' }, + { version: 41, table: 'dispatch_contexts', column: 'creator_principal' }, + { version: 41, table: 'worker_terminal_resources', column: 'principal' } ] 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..6227488f219d 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 + /** Serialized OrchestrationPrincipal; write-only until the resolver PRs. */ + coordinator_principal: 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 + /** Serialized OrchestrationPrincipal; write-only until the resolver PRs. */ + assignee_principal: 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 + /** Serialized OrchestrationPrincipal; write-only until the resolver PRs. */ + creator_principal: string | null host_scope: string | null status: DispatchStatus failure_count: number diff --git a/src/main/runtime/orchestration/worker-terminal-ownership.ts b/src/main/runtime/orchestration/worker-terminal-ownership.ts index 7e613050aa22..e28a242891eb 100644 --- a/src/main/runtime/orchestration/worker-terminal-ownership.ts +++ b/src/main/runtime/orchestration/worker-terminal-ownership.ts @@ -35,6 +35,8 @@ export type WorkerTerminalResourceRow = { worktree_id: string | null terminal_handle: string pane_key: string | null + /** Serialized OrchestrationPrincipal; write-only until the resolver PRs. */ + principal: string | null process_incarnation: string | null endpoint_id: string | null endpoint_incarnation: string | null 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..5adc1e7f75fd 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_principal: 'pane:tab_coord:11111111-1111-4111-8111-111111111111', 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_principal') }) it('preserves every published column by value', () => { 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..be6d7158bc61 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,12 @@ 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 +// Why: home_database, coordinator_pane_key, and coordinator_principal are runtime routing/identity +// state; no caller reads them. coordinator_handle deliberately stays — old clients consume it. +const INTERNAL_RUN_COLUMNS = [ + 'home_database', + 'coordinator_pane_key', + 'coordinator_principal' +] as const export type RunReceipt = Omit From 64e470af0bfd116718190b3aa1753b00f95daf21 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 10 Sep 2026 16:09:40 -0700 Subject: [PATCH 3/4] dual-write principals at every coordinator, dispatch, and resource writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every writer that sets or clears a coordinator/assignee/creator/resource handle or pane key now writes the classified principal alongside, through principalFromPaneKey — never a blind copy, so a structured worker's minted pane key lands as session:. Unbind and legacy adoption clear it. The open-time cache repopulate widens to COALESCE the same way as the triggers; a session coordinator's cached address reroutes its mail through the untouched reader SQL, which the migration test pins. Nothing reads the new columns; dispatch depth keeps its handle/pane path, and the new dispatch-depth test pins the equal-principals-imply-self-created invariant the resolver PR's read switch must preserve. --- .../dispatch-context/dispatch-capability.ts | 5 +- .../orchestration/db/dispatch-depth.test.ts | 38 +++ .../orchestration/db/dispatch-depth.ts | 11 +- .../orchestration/db/dispatch-row-writer.ts | 17 +- .../db/principal-dual-write.test.ts | 203 ++++++++++++ .../orchestration/db/runs/run-binding.ts | 6 +- .../db/runs/run-coordinator-mail-routing.ts | 6 +- .../orchestration/db/runs/run-create.ts | 14 +- .../orchestration/db/runs/run-lookup.ts | 1 + .../db/schema/adopt-legacy-run.ts | 2 +- .../schema/principal-column-migration.test.ts | 311 ++++++++++++++++++ .../worker-dispatch-authority.ts | 5 +- .../failed-start-dispatch-identity.ts | 5 +- .../worker-terminal-resource-store.ts | 12 +- 14 files changed, 614 insertions(+), 22 deletions(-) create mode 100644 src/main/runtime/orchestration/db/principal-dual-write.test.ts create mode 100644 src/main/runtime/orchestration/db/schema/principal-column-migration.test.ts diff --git a/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.ts b/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.ts index 4f6861a17d0a..ba9fb88fbacb 100644 --- a/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.ts +++ b/src/main/runtime/orchestration/db/dispatch-context/dispatch-capability.ts @@ -1,4 +1,5 @@ import { randomBytes, timingSafeEqual } from 'node:crypto' +import { principalFromPaneKey } from '../../../../../shared/orchestration-principal' import { OrchestrationError } from '../../orchestration-error' import { hashDispatchCapability } from '../dispatch-capability-hash' import { isEquivalentPaneKey } from '../pane-key-match' @@ -27,7 +28,8 @@ export function mintDispatchCapability( this.db .prepare( `UPDATE dispatch_contexts - SET capability_hash = ?, assignee_pane_key = ?, process_incarnation = ?, + SET capability_hash = ?, assignee_pane_key = ?, assignee_principal = ?, + process_incarnation = ?, capability_revoked_at = NULL, consumer_generation = consumer_generation + 1 WHERE id = ?` @@ -35,6 +37,7 @@ export function mintDispatchCapability( .run( hashDispatchCapability(capability), params.paneKey, + principalFromPaneKey(params.paneKey), params.processIncarnation, params.dispatchId ) diff --git a/src/main/runtime/orchestration/db/dispatch-depth.test.ts b/src/main/runtime/orchestration/db/dispatch-depth.test.ts index 013cedffe91b..1bee634de105 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.test.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.test.ts @@ -283,4 +283,42 @@ describe('nested worker depth', () => { expect(row.process_incarnation).toBeNull() expect(db.resolveCreatorDepth({ kind: 'terminal', handle: 'term_ctx' })).toBe(1) }) + + it('pins the invariant "equal principals imply self-created": no nesting parent', () => { + // In PR1 this passes through the pane-key path; the resolver PR's read-switch to principal + // equality must keep it true, so it inherits this red test if it breaks the invariant. + db = new OrchestrationDb(':memory:') + const SELF_PANE = 'tab_self:11111111-1111-4111-8111-111111111111' + const task = db.createTask({ runId: 'run_legacy_local', spec: 'own bookkeeping' }) + const row = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'term_self', + assigneePaneKey: SELF_PANE, + creator: { kind: 'terminal', handle: 'term_self', paneKey: SELF_PANE }, + maxDepth: UNCAPPED + }) + expect(row.creator_principal).toBe(`pane:${SELF_PANE}`) + expect(row.creator_principal).toBe(row.assignee_principal) + expect( + db.resolveCreatorDepth({ kind: 'terminal', handle: 'term_self', paneKey: SELF_PANE }) + ).toBe(0) + + // A genuinely delegated row (different panes, different principals) still counts. + const delegated = db.createTask({ runId: 'run_legacy_local', spec: 'delegated' }) + const worker = db.createDispatchContext({ + taskId: delegated.id, + assigneeHandle: 'term_delegate', + assigneePaneKey: 'tab_delegate:22222222-2222-4222-8222-222222222222', + creator: { kind: 'terminal', handle: 'term_self', paneKey: SELF_PANE }, + maxDepth: UNCAPPED + }) + expect(worker.creator_principal).not.toBe(worker.assignee_principal) + 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/dispatch-depth.ts b/src/main/runtime/orchestration/db/dispatch-depth.ts index cc4ba026db93..c5ba63ef9568 100644 --- a/src/main/runtime/orchestration/db/dispatch-depth.ts +++ b/src/main/runtime/orchestration/db/dispatch-depth.ts @@ -4,6 +4,7 @@ import { ROOT_DISPATCH_DEPTH, nestedWorkerDepthExceededMessage } from '../../../../shared/nested-worker-depth' +import { principalFromPaneKey } from '../../../../shared/orchestration-principal' import { OrchestrationError } from '../orchestration-error' import { isEquivalentPaneKey } from './pane-key-match' import type { OrchestrationDb } from './orchestration-db' @@ -32,11 +33,17 @@ export type DispatchCreator = export function recordedCreatorIdentity(creator: DispatchCreator): { creatorHandle: string | null creatorPaneKey: string | null + creatorPrincipal: string | null } { if (creator.kind === 'system') { - return { creatorHandle: null, creatorPaneKey: null } + // Orca's in-process loop is not an actor row; a handle-only creator also stays NULL. + return { creatorHandle: null, creatorPaneKey: null, creatorPrincipal: null } + } + return { + creatorHandle: creator.handle, + creatorPaneKey: creator.paneKey ?? null, + creatorPrincipal: principalFromPaneKey(creator.paneKey) } - return { creatorHandle: creator.handle, creatorPaneKey: creator.paneKey ?? null } } /** diff --git a/src/main/runtime/orchestration/db/dispatch-row-writer.ts b/src/main/runtime/orchestration/db/dispatch-row-writer.ts index 84862ee343d2..c6cd23baa15c 100644 --- a/src/main/runtime/orchestration/db/dispatch-row-writer.ts +++ b/src/main/runtime/orchestration/db/dispatch-row-writer.ts @@ -1,4 +1,5 @@ import type Database from '../../../sqlite/sync-database' +import { principalFromPaneKey } from '../../../../shared/orchestration-principal' import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' /** @@ -14,11 +15,11 @@ import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL } from './pane-key-match' export const DISPATCH_CONTEXT_CLAIM_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, - assignee_handle, assignee_pane_key, process_incarnation, - creator_dispatch_id, creator_handle, creator_pane_key, + assignee_handle, assignee_pane_key, assignee_principal, process_incarnation, + creator_dispatch_id, creator_handle, creator_pane_key, creator_principal, status, failure_count, depth, dispatched_at ) -SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') +SELECT ?, run_id, id, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, ?, datetime('now') FROM tasks WHERE id = ? AND status = 'ready' AND NOT EXISTS ( @@ -45,8 +46,9 @@ WHERE id = ? AND status = 'ready' const STARTING_DISPATCH_CONTEXT_SQL = `INSERT INTO dispatch_contexts ( id, run_id, task_id, contract_version, launch_token_hash, retry_of_dispatch_id, - creator_dispatch_id, creator_handle, creator_pane_key, depth, status, dispatched_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` + creator_dispatch_id, creator_handle, creator_pane_key, creator_principal, + depth, status, dispatched_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', datetime('now'))` const REMOTE_DISPATCH_ATTACHMENT_SQL = `INSERT INTO remote_dispatch_attachments ( dispatch_id, home_run_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch, depth @@ -74,6 +76,7 @@ export function claimDispatchContextRow( creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorPrincipal?: string | null priorFailures: number depth: number taskId: string @@ -89,10 +92,12 @@ export function claimDispatchContextRow( params.launchTokenHash, params.assigneeHandle, params.assigneePaneKey, + principalFromPaneKey(params.assigneePaneKey), params.processIncarnation, params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorPrincipal ?? null, params.priorFailures, params.depth, params.taskId, @@ -118,6 +123,7 @@ export function insertStartingDispatchContextRow( creatorDispatchId?: string | null creatorHandle?: string | null creatorPaneKey?: string | null + creatorPrincipal?: string | null } ): void { assertStampedDepth(params.depth) @@ -131,6 +137,7 @@ export function insertStartingDispatchContextRow( params.creatorDispatchId ?? null, params.creatorHandle ?? null, params.creatorPaneKey ?? null, + params.creatorPrincipal ?? null, params.depth ) } diff --git a/src/main/runtime/orchestration/db/principal-dual-write.test.ts b/src/main/runtime/orchestration/db/principal-dual-write.test.ts new file mode 100644 index 000000000000..fe58a225e6f8 --- /dev/null +++ b/src/main/runtime/orchestration/db/principal-dual-write.test.ts @@ -0,0 +1,203 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from './orchestration-db' +import { structuredAgentSessionTabId } from '../../../../shared/structured-agent-session-tab-id' + +const LEAF1 = '11111111-1111-4111-8111-111111111111' +const LEAF2 = '22222222-2222-4222-8222-222222222222' +const PANE_A = `tab_a:${LEAF1}` +const PANE_B = `tab_b:${LEAF2}` + +describe('principal dual-write', () => { + let db: OrchestrationDb + + afterEach(() => db?.close()) + + function readDispatch(id: string) { + return db.db + .prepare('SELECT assignee_principal, creator_principal FROM dispatch_contexts WHERE id = ?') + .get(id) as { assignee_principal: string | null; creator_principal: string | null } + } + + it('createRun, bindRun, and unbind track the coordinator principal', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'principals', + coordinatorHandle: 'term_a', + coordinatorPaneKey: PANE_A + }) + expect(run.coordinator_principal).toBe(`pane:${PANE_A}`) + + const rebound = db.bindRun({ + runId: run.id, + coordinatorHandle: 'term_b', + coordinatorPaneKey: PANE_B + }) + expect(rebound?.coordinator_principal).toBe(`pane:${PANE_B}`) + + // A second Run claiming the same pane unbinds the first; a stale principal would misattribute + // the Run once reads flip. + db.createRun({ objective: 'usurper', coordinatorHandle: 'term_c', coordinatorPaneKey: PANE_B }) + expect(db.getRunRaw(run.id)?.coordinator_principal).toBeNull() + expect(db.getRunRaw(run.id)?.coordinator_pane_key).toBeNull() + }) + + it('still caches both handles across a same-pane rebind', () => { + db = new OrchestrationDb(':memory:') + const run = db.createRun({ + objective: 'remint', + coordinatorHandle: 'term_a', + coordinatorPaneKey: PANE_A + }) + db.bindRun({ runId: run.id, coordinatorHandle: 'term_reminted', coordinatorPaneKey: PANE_A }) + expect( + db.db + .prepare( + 'SELECT terminal_handle FROM run_coordinator_handles WHERE run_id = ? ORDER BY terminal_handle' + ) + .all(run.id) + ).toEqual([{ terminal_handle: 'term_a' }, { terminal_handle: 'term_reminted' }]) + }) + + it('classifies a structured coordinator pane key to its session principal at createRun', () => { + db = new OrchestrationDb(':memory:') + const paneKey = `${structuredAgentSessionTabId('sess_run')}:${LEAF1}` + const run = db.createRun({ + objective: 'structured', + coordinatorHandle: 'structworker_a', + coordinatorPaneKey: paneKey + }) + expect(run.coordinator_principal).toBe('session:sess_run') + // COALESCE: the handle wins the cache address while one exists; the principal takes over only + // for a handle-less session coordinator (a later PR's writer). + expect( + db.db + .prepare('SELECT terminal_handle FROM run_coordinator_handles WHERE run_id = ?') + .get(run.id) + ).toEqual({ terminal_handle: 'structworker_a' }) + }) + + it('createDispatchContext writes assignee and creator principals by classification', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ runId: 'run_legacy_local', spec: 'assigned' }) + const withPane = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'term_w', + assigneePaneKey: PANE_A, + creator: { kind: 'terminal', handle: 'term_creator', paneKey: PANE_B }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(readDispatch(withPane.id)).toEqual({ + assignee_principal: `pane:${PANE_A}`, + creator_principal: `pane:${PANE_B}` + }) + + const task2 = db.createTask({ runId: 'run_legacy_local', spec: 'bare' }) + const bare = db.createDispatchContext({ + taskId: task2.id, + assigneeHandle: 'term_bare', + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(readDispatch(bare.id)).toEqual({ assignee_principal: null, creator_principal: null }) + }) + + it('classifies a structured assignee pane key at the writer chokepoint', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ runId: 'run_legacy_local', spec: 'structured worker' }) + const paneKey = `${structuredAgentSessionTabId('sess_w')}:${LEAF1}` + const dispatch = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'structworker_w', + assigneePaneKey: paneKey, + creator: { kind: 'system' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(readDispatch(dispatch.id).assignee_principal).toBe('session:sess_w') + }) + + it('a terminal creator with a handle but no pane key records a NULL creator principal', () => { + db = new OrchestrationDb(':memory:') + const task = db.createTask({ runId: 'run_legacy_local', spec: 'handle-only creator' }) + const dispatch = db.createDispatchContext({ + taskId: task.id, + assigneeHandle: 'term_w', + creator: { kind: 'terminal', handle: 'term_creator' }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(readDispatch(dispatch.id).creator_principal).toBeNull() + }) + + it('worker-start leaves assignee NULL until authority attaches, then tracks re-pointing', () => { + db = new OrchestrationDb(':memory:') + const { dispatch } = db.createStartingWorkerDispatch({ + taskSpec: 'supervised', + taskRunId: 'run_legacy_local', + startOptions: {}, + creator: { kind: 'terminal', handle: 'term_creator', paneKey: PANE_B }, + maxDepth: Number.MAX_SAFE_INTEGER + }) + expect(readDispatch(dispatch.id)).toEqual({ + assignee_principal: null, + creator_principal: `pane:${PANE_B}` + }) + + db.prepareStartingWorkerAuthority({ + dispatchId: dispatch.id, + handle: 'term_worker', + paneKey: PANE_A, + processIncarnation: 'inc-1', + worktreeId: 'wt-1', + effects: [], + setupState: 'not_applicable', + terminalOwnership: 'created' + }) + expect(readDispatch(dispatch.id)).toEqual({ + assignee_principal: `pane:${PANE_A}`, + creator_principal: `pane:${PANE_B}` + }) + + // Re-pointing the Dispatch moves the assignee principal and leaves the creator alone. + db.mintDispatchCapability({ + dispatchId: dispatch.id, + paneKey: PANE_B, + processIncarnation: 'inc-2' + }) + expect(readDispatch(dispatch.id)).toEqual({ + assignee_principal: `pane:${PANE_B}`, + creator_principal: `pane:${PANE_B}` + }) + }) + + it('worker terminal resources track pane identity through create and transfer', () => { + db = new OrchestrationDb(':memory:') + const owned = db.createWorkerTerminalResourceStatement({ + dispatchId: 'ctx_owner', + worktreeId: 'wt-1', + terminalHandle: 'term_w', + paneKey: PANE_A, + processIncarnation: 'inc-1', + ownership: 'owned' + }) + expect(owned.principal).toBe(`pane:${PANE_A}`) + + const external = db.createWorkerTerminalResourceStatement({ + dispatchId: 'ctx_external', + worktreeId: 'wt-1', + terminalHandle: 'term_e', + paneKey: null, + processIncarnation: null, + ownership: 'external' + }) + expect(external.principal).toBeNull() + + const transferred = db.transferWorkerTerminalResourceStatement({ + resourceId: owned.id, + toDispatchId: 'ctx_next', + terminalHandle: 'term_w2', + paneKey: PANE_B, + processIncarnation: 'inc-2', + hostScope: null + }) + expect(transferred.principal).toBe(`pane:${PANE_B}`) + }) +}) diff --git a/src/main/runtime/orchestration/db/runs/run-binding.ts b/src/main/runtime/orchestration/db/runs/run-binding.ts index a4110dd589f9..636979813094 100644 --- a/src/main/runtime/orchestration/db/runs/run-binding.ts +++ b/src/main/runtime/orchestration/db/runs/run-binding.ts @@ -1,3 +1,4 @@ +import { principalFromPaneKey } from '../../../../../shared/orchestration-principal' import type { RunRow } from '../../types' import { OrchestrationError } from '../../orchestration-error' import { LEGACY_CONTRACT_VERSION } from '../contract-constants' @@ -109,6 +110,7 @@ export function bindRun( } ) } + const incomingPrincipal = principalFromPaneKey(params.coordinatorPaneKey) this.unbindOtherRunsForPane(params.coordinatorPaneKey, params.runId) for (const handle of new Set( [run.coordinator_handle, params.coordinatorHandle].filter((value): value is string => @@ -136,12 +138,12 @@ export function bindRun( this.db .prepare( `UPDATE runs - SET coordinator_handle = ?, coordinator_pane_key = ?, + SET coordinator_handle = ?, coordinator_pane_key = ?, coordinator_principal = ?, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` ) - .run(params.coordinatorHandle, params.coordinatorPaneKey, params.runId) + .run(params.coordinatorHandle, params.coordinatorPaneKey, incomingPrincipal, params.runId) this.fenceOutstandingDelivery(params.runId) if (params.takeoverLegacy || replacesLegacyCoordinator) { this.promoteLegacyCoordinatorMailForTakeover(params.runId, retainedCoordinatorHandle) 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..d66e026a2180 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,13 @@ export function rememberRunCoordinatorHandle( .run(runId, terminalHandle) } +// COALESCE: a handle-less session coordinator is cached by its principal, which is a mailbox +// address like any other, so the string-equality readers match it unchanged. 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_principal) FROM runs + WHERE legacy = 0 AND (coordinator_handle IS NOT NULL OR coordinator_principal IS NOT NULL) `) } diff --git a/src/main/runtime/orchestration/db/runs/run-create.ts b/src/main/runtime/orchestration/db/runs/run-create.ts index 3f99e0189362..e8ea7fd130e5 100644 --- a/src/main/runtime/orchestration/db/runs/run-create.ts +++ b/src/main/runtime/orchestration/db/runs/run-create.ts @@ -1,3 +1,4 @@ +import { principalFromPaneKey } from '../../../../../shared/orchestration-principal' import type { RunRow } from '../../types' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' @@ -13,17 +14,24 @@ export function createRun( } ): RunRow { const id = generateId('run') + const coordinatorPrincipal = principalFromPaneKey(params.coordinatorPaneKey) this.db.exec('BEGIN IMMEDIATE') try { this.unbindOtherRunsForPane(params.coordinatorPaneKey) this.db .prepare( `INSERT INTO runs ( - id, objective, coordinator_handle, coordinator_pane_key, + id, objective, coordinator_handle, coordinator_pane_key, coordinator_principal, consumer_generation, legacy - ) VALUES (?, ?, ?, ?, 1, 0)` + ) VALUES (?, ?, ?, ?, ?, 1, 0)` + ) + .run( + id, + params.objective, + params.coordinatorHandle, + params.coordinatorPaneKey, + coordinatorPrincipal ) - .run(id, params.objective, params.coordinatorHandle, params.coordinatorPaneKey) this.rememberRunCoordinatorHandle(id, params.coordinatorHandle) this.db.exec('COMMIT') } catch (error) { diff --git a/src/main/runtime/orchestration/db/runs/run-lookup.ts b/src/main/runtime/orchestration/db/runs/run-lookup.ts index 7563effa8c3d..291bbf79b29c 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_principal = NULL, consumer_generation = consumer_generation + 1, updated_at = datetime('now') WHERE id = ?` diff --git a/src/main/runtime/orchestration/db/schema/adopt-legacy-run.ts b/src/main/runtime/orchestration/db/schema/adopt-legacy-run.ts index a67a6a2f6047..a7bb05b29547 100644 --- a/src/main/runtime/orchestration/db/schema/adopt-legacy-run.ts +++ b/src/main/runtime/orchestration/db/schema/adopt-legacy-run.ts @@ -77,7 +77,7 @@ export function adoptLegacyRunIfNeeded(this: OrchestrationDb): void { .prepare( `UPDATE runs SET objective = 'Legacy orchestration state (adopted; inspect only)', - coordinator_handle = NULL, coordinator_pane_key = NULL, + coordinator_handle = NULL, coordinator_pane_key = NULL, coordinator_principal = NULL, updated_at = datetime('now') WHERE id = ?` ) diff --git a/src/main/runtime/orchestration/db/schema/principal-column-migration.test.ts b/src/main/runtime/orchestration/db/schema/principal-column-migration.test.ts new file mode 100644 index 000000000000..0c4fe13e1adb --- /dev/null +++ b/src/main/runtime/orchestration/db/schema/principal-column-migration.test.ts @@ -0,0 +1,311 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import Database from '../../../../sqlite/sync-database' +import { afterEach, describe, expect, it } from 'vitest' +import { OrchestrationDb } from '../orchestration-db' +import { SCHEMA_VERSION } from '../contract-constants' +import { migrateV41 } from './migrate-v41' + +const LEAF1 = '11111111-1111-4111-8111-111111111111' +const LEAF2 = '22222222-2222-4222-8222-222222222222' +const LEAF3 = '33333333-3333-4333-8333-333333333333' +const STRUCTURED_PANE = `structured-agent-session-sess1:${LEAF2}` + +/** Puts an already-migrated database back into v40 shape for the direct-unit cases. */ +function revertToV40Shape(db: OrchestrationDb): void { + db.db.exec(` + DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_insert; + DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_update; + ALTER TABLE runs DROP COLUMN coordinator_principal; + ALTER TABLE dispatch_contexts DROP COLUMN assignee_principal; + ALTER TABLE dispatch_contexts DROP COLUMN creator_principal; + ALTER TABLE worker_terminal_resources DROP COLUMN principal; + `) +} + +function seedV40Rows(db: OrchestrationDb): void { + db.db.exec(` + INSERT INTO runs (id, objective, coordinator_handle, coordinator_pane_key, legacy) + VALUES ('run_paned', 'paned', 'term_a', 'tab_a:${LEAF1}', 0); + INSERT INTO runs (id, objective, coordinator_handle, coordinator_pane_key, legacy) + VALUES ('run_structured', 'structured', 'structworker_x', '${STRUCTURED_PANE}', 0); + INSERT INTO runs (id, objective, legacy) VALUES ('run_unbound', 'unbound', 0); + INSERT INTO dispatch_contexts (id, task_id, assignee_pane_key, creator_pane_key, status) + VALUES ('ctx_paned', 't1', 'tab_b:${LEAF1}', 'tab_c:${LEAF3}', 'dispatched'); + INSERT INTO dispatch_contexts (id, task_id, status) VALUES ('ctx_null', 't2', 'pending'); + INSERT INTO worker_terminal_resources (id, origin_dispatch_id, owner_dispatch_id, terminal_handle, pane_key) + VALUES ('wtr_paned', 'ctx_paned', 'ctx_paned', 'term_w', 'tab_d:${LEAF3}'); + INSERT INTO worker_terminal_resources (id, origin_dispatch_id, owner_dispatch_id, terminal_handle) + VALUES ('wtr_null', 'ctx_null', 'ctx_null', 'term_x'); + `) +} + +function principalSnapshot(db: OrchestrationDb): unknown[] { + return [ + ...db.db.prepare('SELECT id, coordinator_principal FROM runs ORDER BY id').all(), + ...db.db + .prepare( + 'SELECT id, assignee_principal, creator_principal FROM dispatch_contexts ORDER BY id' + ) + .all(), + ...db.db.prepare('SELECT id, principal FROM worker_terminal_resources ORDER BY id').all() + ] +} + +describe('principal column migration', () => { + const tempRoots: string[] = [] + + afterEach(() => { + while (tempRoots.length > 0) { + rmSync(tempRoots.pop() as string, { recursive: true, force: true }) + } + }) + + function tempDbPath(): string { + const root = mkdtempSync(join(tmpdir(), 'orca-principal-migration-')) + tempRoots.push(root) + return join(root, 'orchestration.db') + } + + it('v40 -> v41 migrates and backfills by classification, and is idempotent', () => { + const db = new OrchestrationDb(':memory:') + try { + revertToV40Shape(db) + seedV40Rows(db) + migrateV41.call(db, 40) + + expect(principalSnapshot(db)).toEqual([ + { id: 'run_legacy_local', coordinator_principal: null }, + { id: 'run_paned', coordinator_principal: `pane:tab_a:${LEAF1}` }, + { id: 'run_structured', coordinator_principal: 'session:sess1' }, + { id: 'run_unbound', coordinator_principal: null }, + { id: 'ctx_null', assignee_principal: null, creator_principal: null }, + { + id: 'ctx_paned', + assignee_principal: `pane:tab_b:${LEAF1}`, + creator_principal: `pane:tab_c:${LEAF3}` + }, + { id: 'wtr_null', principal: null }, + { id: 'wtr_paned', principal: `pane:tab_d:${LEAF3}` } + ]) + // Non-identity columns untouched. + expect( + db.db.prepare('SELECT objective, legacy FROM runs WHERE id = ?').get('run_paned') + ).toEqual({ objective: 'paned', legacy: 0 }) + + const before = principalSnapshot(db) + migrateV41.call(db, 40) + expect(principalSnapshot(db)).toEqual(before) + } finally { + db.close() + } + }) + + it('runs the real chain from a seeded v40 file database, cache and triggers included', () => { + const path = tempDbPath() + const seed = new Database(path) + // v40 shapes for exactly the tables v41 touches; createTables supplies every other table, and + // a 40 stamp survives the completeness probe so the migration start resolves to 40. + seed.exec(` + CREATE TABLE runs ( + id TEXT PRIMARY KEY, + objective TEXT NOT NULL, + home_database TEXT NOT NULL DEFAULT 'this_database', + coordinator_handle TEXT, + coordinator_pane_key TEXT, + consumer_generation INTEGER NOT NULL DEFAULT 0, + legacy INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + CREATE TABLE run_coordinator_handles ( + run_id TEXT NOT NULL, + terminal_handle TEXT NOT NULL, + first_bound_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (run_id, terminal_handle) + ); + 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; + INSERT INTO runs (id, objective, coordinator_handle, coordinator_pane_key, legacy) + VALUES ('run_paned', 'paned', 'term_a', 'tab_a:${LEAF1}', 0); + INSERT INTO runs (id, objective, coordinator_handle, coordinator_pane_key, legacy) + VALUES ('run_structured', 'structured', 'structworker_x', '${STRUCTURED_PANE}', 0); + INSERT INTO run_coordinator_handles (run_id, terminal_handle) VALUES ('run_paned', 'term_old'); + `) + seed.pragma('user_version = 40') + seed.close() + + const db = new OrchestrationDb(path) + try { + expect(db.db.pragma('user_version', { simple: true })).toBe(SCHEMA_VERSION) + expect( + db.db.prepare('SELECT coordinator_principal FROM runs WHERE id = ?').get('run_paned') + ).toEqual({ coordinator_principal: `pane:tab_a:${LEAF1}` }) + expect( + db.db.prepare('SELECT coordinator_principal FROM runs WHERE id = ?').get('run_structured') + ).toEqual({ coordinator_principal: 'session:sess1' }) + // Cache shape unchanged: handle-bearing coordinators stay handle-keyed, history included. + expect( + db.db + .prepare( + 'SELECT run_id, terminal_handle FROM run_coordinator_handles ORDER BY run_id, terminal_handle' + ) + .all() + ).toEqual([ + { run_id: 'run_paned', terminal_handle: 'term_a' }, + { run_id: 'run_paned', terminal_handle: 'term_old' }, + { run_id: 'run_structured', terminal_handle: 'structworker_x' } + ]) + // The DROP/recreate replaced the old-predicate triggers; CREATE TRIGGER IF NOT EXISTS alone + // would have kept the handle-only form on this upgraded database. + const triggerSql = db.db + .prepare( + "SELECT name, sql FROM sqlite_master WHERE type = 'trigger' AND name IN " + + "('trg_runs_remember_coordinator_insert', 'trg_runs_remember_coordinator_update')" + ) + .all() as { name: string; sql: string }[] + expect(triggerSql).toHaveLength(2) + for (const trigger of triggerSql) { + expect(trigger.sql).toContain('coordinator_principal') + } + } finally { + db.close() + } + }) + + it('repairs rows a rolled-back binary wrote after user_version reached 41', () => { + const path = tempDbPath() + const first = new OrchestrationDb(path) + // A pre-v41 binary's insert shape: explicit columns, no principals. + first.db.exec(` + INSERT INTO dispatch_contexts (id, task_id, assignee_pane_key, creator_pane_key, status) + VALUES ('ctx_rolled_back', 't1', 'tab_b:${LEAF1}', 'tab_c:${LEAF3}', 'dispatched'); + INSERT INTO dispatch_contexts (id, task_id, assignee_pane_key, assignee_principal, status) + VALUES ('ctx_stale', 't2', 'tab_b:${LEAF1}', 'pane:tab_b:${LEAF1}', 'dispatched'); + `) + // A pre-v41 binary re-points the pane without touching the principal, leaving it stale. + first.db + .prepare('UPDATE dispatch_contexts SET assignee_pane_key = ? WHERE id = ?') + .run(`tab_z:${LEAF2}`, 'ctx_stale') + first.close() + + const reopened = new OrchestrationDb(path) + try { + expect( + reopened.db + .prepare( + 'SELECT assignee_principal, creator_principal FROM dispatch_contexts WHERE id = ?' + ) + .get('ctx_rolled_back') + ).toEqual({ + assignee_principal: `pane:tab_b:${LEAF1}`, + creator_principal: `pane:tab_c:${LEAF3}` + }) + expect( + reopened.db + .prepare('SELECT assignee_principal FROM dispatch_contexts WHERE id = ?') + .get('ctx_stale') + ).toEqual({ assignee_principal: `pane:tab_z:${LEAF2}` }) + } finally { + reopened.close() + } + }) + + it('never touches a session principal on reopen, even when its pane columns disagree', () => { + const path = tempDbPath() + const first = new OrchestrationDb(path) + // A session row's pane columns hold the minted fake key by design; the reconcile must not + // re-assert a pane: value over it on every open. + first.db.exec(` + INSERT INTO dispatch_contexts (id, task_id, assignee_pane_key, assignee_principal, status) + VALUES ('ctx_session', 't1', 'tab_b:${LEAF1}', 'session:s1', 'dispatched'); + `) + first.close() + + const reopened = new OrchestrationDb(path) + try { + expect( + reopened.db + .prepare('SELECT assignee_principal FROM dispatch_contexts WHERE id = ?') + .get('ctx_session') + ).toEqual({ assignee_principal: 'session:s1' }) + } finally { + reopened.close() + } + }) + + it('caches a handle-less session coordinator row for the seam later PRs read', () => { + const path = tempDbPath() + const db = new OrchestrationDb(path) + // Raw SQL is honest here: no PR1 production writer can produce such a row; this pins the seam + // PR3 (read side) and PR5 (session-row producer) build on. + db.db.exec(` + INSERT INTO runs (id, objective, coordinator_principal, legacy) + VALUES ('run_session', 'session coordinator', 'session:s1', 0); + `) + expect( + db.db + .prepare('SELECT terminal_handle FROM run_coordinator_handles WHERE run_id = ?') + .all('run_session') + ).toEqual([{ terminal_handle: 'session:s1' }]) + // COALESCE puts the principal in the mailbox-address column, so the existing reroute trigger + // matches it by plain string equality with zero read-side change. + db.db.exec(` + INSERT INTO messages (id, run_id, from_handle, to_handle, subject) + VALUES ('msg_1', 'run_session', 'term_worker', 'session:s1', 'reply'); + `) + expect(db.db.prepare('SELECT to_handle FROM messages WHERE id = ?').get('msg_1')).toEqual({ + to_handle: 'run:run_session' + }) + db.close() + + // Reopen: repopulate adds no duplicate (PK + INSERT OR IGNORE). + const reopened = new OrchestrationDb(path) + try { + expect( + reopened.db + .prepare('SELECT COUNT(*) AS rows FROM run_coordinator_handles WHERE run_id = ?') + .get('run_session') + ).toEqual({ rows: 1 }) + // Zero-behavior-change guard: createRun/bindRun still produce exactly the handle-keyed rows + // they produce on main. + const run = reopened.createRun({ + objective: 'guard', + coordinatorHandle: 'term_guard', + coordinatorPaneKey: `tab_guard:${LEAF1}` + }) + expect( + reopened.db + .prepare('SELECT terminal_handle FROM run_coordinator_handles WHERE run_id = ?') + .all(run.id) + ).toEqual([{ terminal_handle: 'term_guard' }]) + reopened.bindRun({ + runId: run.id, + coordinatorHandle: 'term_rebound', + coordinatorPaneKey: `tab_rebound:${LEAF2}` + }) + expect( + reopened.db + .prepare( + 'SELECT terminal_handle FROM run_coordinator_handles WHERE run_id = ? ORDER BY terminal_handle' + ) + .all(run.id) + ).toEqual([{ terminal_handle: 'term_guard' }, { terminal_handle: 'term_rebound' }]) + } finally { + reopened.close() + } + }) +}) 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 57ec354a8cce..bc2edc14b7e1 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 @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto' +import { principalFromPaneKey } from '../../../../../shared/orchestration-principal' import { OrchestrationError } from '../../orchestration-error' import { hashDispatchCapability } from '../dispatch-capability-hash' import type { OrchestrationDb } from '../orchestration-db' @@ -53,7 +54,8 @@ export function prepareStartingWorkerAuthority( const contextUpdate = this.db .prepare( `UPDATE dispatch_contexts - SET assignee_handle = ?, assignee_pane_key = ?, process_incarnation = ?, + SET assignee_handle = ?, assignee_pane_key = ?, assignee_principal = ?, + process_incarnation = ?, host_scope = ?, capability_hash = ?, launch_token_hash = COALESCE(launch_token_hash, ?), capability_revoked_at = NULL, @@ -63,6 +65,7 @@ export function prepareStartingWorkerAuthority( .run( params.handle, params.paneKey, + principalFromPaneKey(params.paneKey), params.processIncarnation, params.hostScope ?? null, hashDispatchCapability(capability), diff --git a/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts b/src/main/runtime/orchestration/db/worker-terminal/failed-start-dispatch-identity.ts index 671b12c39a71..ba8e06f7ea82 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,12 +21,15 @@ 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 = ?, assignee_principal = ?, + process_incarnation = ?, host_scope = ? WHERE id = ? AND status = 'failed' AND capability_hash IS NULL` ) .run( resource.terminal_handle, resource.pane_key, + // Copied, not re-derived: a future session resource keeps its identity through this path. + resource.principal, resource.process_incarnation, resource.host_scope, worker.dispatch_id diff --git a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts index e6942503dbf3..b26db44316fb 100644 --- a/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts +++ b/src/main/runtime/orchestration/db/worker-terminal/worker-terminal-resource-store.ts @@ -3,6 +3,7 @@ import type { WorkerTerminalOwnershipState } from '../../worker-terminal-ownership' import { WORKER_SETTLED_STATES } from '../../worker-terminal-ownership' +import { principalFromPaneKey } from '../../../../../shared/orchestration-principal' import { OrchestrationError } from '../../orchestration-error' import { generateId } from '../generated-id' import type { OrchestrationDb } from '../orchestration-db' @@ -72,9 +73,9 @@ export function createWorkerTerminalResourceStatement( .prepare( `INSERT INTO worker_terminal_resources ( id, origin_dispatch_id, owner_dispatch_id, worktree_id, terminal_handle, - pane_key, process_incarnation, endpoint_id, endpoint_incarnation, host_scope, ownership_state, release_state, - retained_reason - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'not_requested', ?)` + pane_key, principal, process_incarnation, endpoint_id, endpoint_incarnation, host_scope, + ownership_state, release_state, retained_reason + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'not_requested', ?)` ) .run( id, @@ -83,6 +84,7 @@ export function createWorkerTerminalResourceStatement( params.worktreeId, params.terminalHandle, params.paneKey, + principalFromPaneKey(params.paneKey), params.processIncarnation, params.endpointId ?? null, params.endpointIncarnation ?? params.processIncarnation, @@ -181,7 +183,8 @@ export function transferWorkerTerminalResourceStatement( `UPDATE worker_terminal_resources SET owner_dispatch_id = ?, prior_owner_dispatch_ids = ?, release_state = 'not_requested', retained_reason = NULL, release_requested_at = NULL, release_completed_at = NULL, - release_error = NULL, terminal_handle = ?, pane_key = ?, process_incarnation = ?, + release_error = NULL, terminal_handle = ?, pane_key = ?, principal = ?, + process_incarnation = ?, endpoint_id = COALESCE(?, endpoint_id), endpoint_incarnation = ?, host_scope = ?, updated_at = datetime('now') WHERE id = ? AND ownership_state = 'owned'` @@ -191,6 +194,7 @@ export function transferWorkerTerminalResourceStatement( JSON.stringify(priorOwners), params.terminalHandle, params.paneKey, + principalFromPaneKey(params.paneKey), params.processIncarnation, params.endpointId ?? null, params.endpointIncarnation ?? params.processIncarnation, From 7b46cc4aef9b91acdcbe11fc535b3a59f95bcdfb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:57:58 -0700 Subject: [PATCH 4/4] test(orchestration): name the migration-revert helper for its domain role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename to revertToV41Shape tripped the naming audit — "shape" describes structure rather than what the helper does, which is undo the principal migration. --- .../db/schema/principal-column-migration.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/runtime/orchestration/db/schema/principal-column-migration.test.ts b/src/main/runtime/orchestration/db/schema/principal-column-migration.test.ts index 8ead36086a1d..3eef5cae8ec3 100644 --- a/src/main/runtime/orchestration/db/schema/principal-column-migration.test.ts +++ b/src/main/runtime/orchestration/db/schema/principal-column-migration.test.ts @@ -12,8 +12,8 @@ const LEAF2 = '22222222-2222-4222-8222-222222222222' const LEAF3 = '33333333-3333-4333-8333-333333333333' const STRUCTURED_PANE = `structured-agent-session-sess1:${LEAF2}` -/** Puts an already-migrated database back into pre-principal (v41) shape for the direct-unit cases. */ -function revertToV41Shape(db: OrchestrationDb): void { +/** Undoes the principal migration so the direct-unit cases start from a v41 database. */ +function revertPrincipalMigration(db: OrchestrationDb): void { db.db.exec(` DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_insert; DROP TRIGGER IF EXISTS trg_runs_remember_coordinator_update; @@ -71,7 +71,7 @@ describe('principal column migration', () => { it('v41 -> v42 migrates and backfills by classification, and is idempotent', () => { const db = new OrchestrationDb(':memory:') try { - revertToV41Shape(db) + revertPrincipalMigration(db) seedV41Rows(db) migrateV42.call(db, 41)