diff --git a/src/cli/orchestration-session-caller-cli.test.ts b/src/cli/orchestration-session-caller-cli.test.ts index 402b9d689e80..035108604f2b 100644 --- a/src/cli/orchestration-session-caller-cli.test.ts +++ b/src/cli/orchestration-session-caller-cli.test.ts @@ -307,7 +307,7 @@ describe.each(CALLER_VERBS)('orchestration $command run as an agent session', (v } ) - it.runIf(verb.callerFlag !== undefined).each([`session:${SESSION}`, SESSION])( + it.runIf(verb.callerFlag !== undefined).each([`orca_session_id:${SESSION}`, SESSION])( 'accepts a caller flag that restates the session (%s)', async (restated) => { await invoke(verb.command, flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: restated })) @@ -326,11 +326,14 @@ describe.each(CALLER_VERBS.filter((verb) => verb.callerFlag !== undefined))( it('sends the address it had before the clear for the host to place, instead of refusing it', async () => { // The chat's address is its lineage root's, which only the host's session records know. - const flags = flagMap({ ...verb.flags, [verb.callerFlag ?? 'from']: `session:${ROOT}` }) + const flags = flagMap({ + ...verb.flags, + [verb.callerFlag ?? 'from']: `orca_session_id:${ROOT}` + }) await invoke(verb.command, flags) const [params] = callsTo(verb.method) - expect(params?.[verb.callerParam]).toBe(`session:${ROOT}`) + expect(params?.[verb.callerParam]).toBe(`orca_session_id:${ROOT}`) expect(params?.terminalPaneKey).toBeUndefined() expect(params?.senderPaneKey).toBeUndefined() expect(getTerminalHandleMock).not.toHaveBeenCalled() @@ -357,11 +360,11 @@ describe.each([ }) it('accepts a --from that restates the session', async () => { - await invoke(command, flagMap({ run: 'run_1', from: `session:${SESSION}` })) + await invoke(command, flagMap({ run: 'run_1', from: `orca_session_id:${SESSION}` })) expect(callsTo(method)[0]?.[callerParam]).toBeUndefined() }) - it.each([`session:${ROOT}`, 'session:9d4c1b2a-3e5f-4a6b-8c7d-0e1f2a3b4c5d'])( + it.each([`orca_session_id:${ROOT}`, 'orca_session_id:9d4c1b2a-3e5f-4a6b-8c7d-0e1f2a3b4c5d'])( 'sends a session address it cannot place (%s) for the host to check, never dropping it', async (declared) => { // A /clear-ed chat's root is accepted by the host; anyone else is refused there, before any @@ -441,7 +444,7 @@ describe('the identity a session presents', () => { return callsTo('dispatchShow')[0]?.from } asSessionWithInheritedPane() - expect(await preview({})).toBe(`session:${SESSION}`) + expect(await preview({})).toBe(`orca_session_id:${SESSION}`) // Not a caller flag: it names the text to preview, so it is never fenced. expect(await preview({ from: 'term_sibling' })).toBe('term_sibling') setEnv({ ORCA_AGENT_SESSION_ID: SESSION, ORCA_TERMINAL_HANDLE: 'structworker_self' }) diff --git a/src/main/runtime/orchestration/db/contract-constants.ts b/src/main/runtime/orchestration/db/contract-constants.ts index f2a8fc12fd1b..98dc8d910cdb 100644 --- a/src/main/runtime/orchestration/db/contract-constants.ts +++ b/src/main/runtime/orchestration/db/contract-constants.ts @@ -19,4 +19,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 Orca session id columns. -export const SCHEMA_VERSION = 42 +// v43: stored `session:` addresses respelled `orca_session_id:`. +export const SCHEMA_VERSION = 43 diff --git a/src/main/runtime/orchestration/db/orca-session-address-sql.ts b/src/main/runtime/orchestration/db/orca-session-address-sql.ts index 83bb7ad2c56e..81ec5783d505 100644 --- a/src/main/runtime/orchestration/db/orca-session-address-sql.ts +++ b/src/main/runtime/orchestration/db/orca-session-address-sql.ts @@ -1,7 +1,7 @@ import { ORCA_SESSION_ADDRESS_PREFIX } from '../../../../shared/orca-session-address' /** - * The `session:` address of a bare Orca session id column or expression, NULL when it is NULL. + * The `orca_session_id:` address of a bare Orca session id column or expression, NULL when it is NULL. * The only way SQL compares a stored id with a mail address: the id side is formatted, never the * address side stripped, so a handle or `run:` address can never equal a bare id. */ diff --git a/src/main/runtime/orchestration/db/orchestration-db.ts b/src/main/runtime/orchestration/db/orchestration-db.ts index 7bd0d65517f4..e12857399371 100644 --- a/src/main/runtime/orchestration/db/orchestration-db.ts +++ b/src/main/runtime/orchestration/db/orchestration-db.ts @@ -5,6 +5,7 @@ import { backfillFederatedStubHomeRuns } from './federation/federated-stub-home- import type { OrchestrationDbMethods } from './orchestration-db-methods' import { createCoordinatorMailRoutingTrigger, + createRunCoordinatorAddressTriggers, rememberCurrentRunCoordinatorHandles } from './runs/run-coordinator-mail-routing' import { createTables } from './schema/create-tables' @@ -30,6 +31,7 @@ class OrchestrationDbCore { this.db.pragma('busy_timeout = 5000') createTables.call(this as unknown as OrchestrationDb) migrate.call(this as unknown as OrchestrationDb) + createRunCoordinatorAddressTriggers(this.db) backfillFederatedStubHomeRuns(this.db) backfillStructuredWorkerOrcaSessionIds(this.db) createCoordinatorMailRoutingTrigger.call(this as unknown as OrchestrationDb) 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 d42b93dfdd9f..c1f80a592036 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,3 +1,4 @@ +import type Database from '../../../../sqlite/sync-database' import type { OrchestrationDb } from '../orchestration-db' import { currentRunCoordinatorSessionAddressSql } from './run-coordinator-orca-session' @@ -24,8 +25,51 @@ export function rememberRunCoordinatorHandle( } const CURRENT_COORDINATOR_SESSION_ADDRESS_SQL = currentRunCoordinatorSessionAddressSql('runs') +const NEW_COORDINATOR_SESSION_ADDRESS_SQL = currentRunCoordinatorSessionAddressSql('NEW') -// Every address the coordinator has, its handle and its session address, as the migrate-v42 triggers. +/** + * Remembers every address the coordinator has, as bindRun does: its handle and its session address, + * so every reader of this cache matches either by string equality and neither takes precedence. + * Recreated on every open, after migrate: the address prefix is compiled into the trigger body, + * so a trigger stamped once would keep an older build's prefix. The static createTables SQL stays + * handle-only (see create-core-tables-sql), since it must compile against a pre-v42 runs table. + */ +export function createRunCoordinatorAddressTriggers(db: Database.Database): void { + db.exec('BEGIN IMMEDIATE') + try { + 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 + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + 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 + BEGIN + INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) + 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; + `) + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + +// Every address the coordinator has, its handle and its session address, as the triggers above. 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 index 007e979df4d8..c7bd12406831 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 @@ -26,7 +26,7 @@ export function currentRunCoordinatorOrcaSessionIdSql(row: string): string { THEN ${row}.coordinator_orca_session_id END)` } -/** The coordinator's `session:` address in SQL; NULL when it has no current Orca session id. */ +/** The coordinator's `orca_session_id:` address in SQL; NULL when it has no current Orca session id. */ export function currentRunCoordinatorSessionAddressSql(row: string): string { return orcaSessionAddressSql(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 ecc99241e35b..ed55c5bf173a 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 @@ -61,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 the --- coordinator's session address. This SQL runs before migrate on every open, so it +-- Handle-only on purpose; createRunCoordinatorAddressTriggers replaces both on every open with a +-- form that also remembers the coordinator's session address. This SQL runs before migrate, 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 e0a7a0bef2d4..d77ab67f64ca 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 @@ -146,7 +146,7 @@ CREATE TABLE IF NOT EXISTS dispatch_contexts ( assignee_handle TEXT, assignee_pane_key TEXT, -- 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. + -- sessions); for a /clear'd chat, its lineage root's. Not its orca_session_id: 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 e8d55037abc8..c600386661ac 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 { currentRunCoordinatorSessionAddressSql } from '../runs/run-coordinator-orca-session' const ORCA_SESSION_ID_COLUMNS = [ ['runs', 'coordinator_orca_session_id', 'TEXT'], @@ -8,15 +7,14 @@ const ORCA_SESSION_ID_COLUMNS = [ ['dispatch_contexts', 'creator_orca_session_id', 'TEXT'] ] as const -const NEW_COORDINATOR_SESSION_ADDRESS_SQL = currentRunCoordinatorSessionAddressSql('NEW') - /** * 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); 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. + * written at and counts only at that generation. The triggers that remember a coordinator's session + * address are `createRunCoordinatorAddressTriggers`, recreated on every open after migrate. * * 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 @@ -39,34 +37,4 @@ 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; `) - // 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(` - 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 - BEGIN - INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - 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 - BEGIN - INSERT OR IGNORE INTO run_coordinator_handles (run_id, terminal_handle) - 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/db/schema/migrate-v43.ts b/src/main/runtime/orchestration/db/schema/migrate-v43.ts new file mode 100644 index 000000000000..c3797ee33705 --- /dev/null +++ b/src/main/runtime/orchestration/db/schema/migrate-v43.ts @@ -0,0 +1,43 @@ +import type { OrchestrationDb } from '../orchestration-db' + +// Frozen literals, never the live prefix constant: a migration rewrites what that build wrote. +const LEGACY_PREFIX = 'session:' +const CURRENT_PREFIX = 'orca_session_id:' + +// Every column an earlier build wrote a session's mail address into. Bodies, subjects and payloads +// are text, not addresses, and are left as written. +const ADDRESS_COLUMNS = [ + ['messages', 'from_handle'], + ['messages', 'to_handle'], + ['coordinator_runs', 'coordinator_handle'] +] as const + +// Address columns in a key: where the current spelling already has its row, the legacy one is a duplicate. +const KEYED_ADDRESS_COLUMNS = [ + ['run_coordinator_handles', 'terminal_handle'], + ['structured_pointer_operations', 'mailbox_handle'] +] as const + +/** + * Earlier builds addressed an Orca session as `session:`. Respells every stored one as + * `orca_session_id:`, so a chat still reads mail sent before the upgrade, a Run still routes + * mail to the chat that coordinated it, and a reply to old mail reaches its sender. + */ +export function migrateV43(this: OrchestrationDb, current: number): void { + if (current >= 43) { + return + } + const isLegacy = (column: string): string => + `substr(${column}, 1, ${LEGACY_PREFIX.length}) = '${LEGACY_PREFIX}'` + const respelled = (column: string): string => + `'${CURRENT_PREFIX}' || substr(${column}, ${LEGACY_PREFIX.length + 1})` + for (const [table, column] of ADDRESS_COLUMNS) { + this.db.exec(`UPDATE ${table} SET ${column} = ${respelled(column)} WHERE ${isLegacy(column)}`) + } + for (const [table, column] of KEYED_ADDRESS_COLUMNS) { + this.db.exec(` + UPDATE OR IGNORE ${table} SET ${column} = ${respelled(column)} WHERE ${isLegacy(column)}; + DELETE FROM ${table} WHERE ${isLegacy(column)}; + `) + } +} diff --git a/src/main/runtime/orchestration/db/schema/migrate.ts b/src/main/runtime/orchestration/db/schema/migrate.ts index c02eeaf7cb38..64039e820873 100644 --- a/src/main/runtime/orchestration/db/schema/migrate.ts +++ b/src/main/runtime/orchestration/db/schema/migrate.ts @@ -13,6 +13,7 @@ import { migrateV39 } from './migrate-v39' import { migrateV40 } from './migrate-v40' import { DERIVED_DELIVERY_SCHEMA_SQL, migrateV41 } from './migrate-v41' import { migrateV42 } from './migrate-v42' +import { migrateV43 } from './migrate-v43' // 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 { @@ -40,6 +41,7 @@ export function migrate(this: OrchestrationDb): void { // Why: older steps recreate the unique index; v41 must run after them. migrateV41.call(this, current) migrateV42.call(this, current) + migrateV43.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/orchestration-caller-identity.ts b/src/main/runtime/orchestration/orchestration-caller-identity.ts index cce3cecd5484..9e34634ff3eb 100644 --- a/src/main/runtime/orchestration/orchestration-caller-identity.ts +++ b/src/main/runtime/orchestration/orchestration-caller-identity.ts @@ -7,7 +7,7 @@ import { formatOrcaSessionAddress, type OrcaSessionId } from '../../../shared/or * Who an orchestration caller is, as Run binding and mail routing match it. * * A PTY agent is its terminal: a handle and a pane key, no Orca session id. An agent that is a - * structured session is its Orca session id, addressed as `session:`; a structured worker also + * structured session is its Orca session id, addressed as `orca_session_id:`; a structured worker also * has the handle and pane key it was minted, and an ordinary chat has neither. Methods pass this * through whole and never branch on which fields are set; the lookups below own that. */ @@ -16,7 +16,7 @@ export type OrchestrationCallerIdentity = Readonly<{ address: string terminalHandle: string | null paneKey: string | null - /** The bare Orca session id the caller is addressed by; mail spells it `session:`. */ + /** The bare Orca session id the caller is addressed by; mail spells it `orca_session_id:`. */ orcaSessionId: OrcaSessionId | null }> 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 4dd07052d6fa..dc5109b6e310 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 @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { + ORCA_SESSION_ADDRESS_PREFIX, formatOrcaSessionAddress, parseOrcaSessionAddress } from '../../../shared/orca-session-address' @@ -70,7 +71,7 @@ describe('Run coordinator Orca session address', () => { db = new OrchestrationDb(':memory:') insertSessionCoordinatedRun(db, 'run_session') - // The column holds the bare id; only the remembered address carries the session: prefix. + // The column holds the bare id; only the remembered address carries the orca_session_id: 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) @@ -109,6 +110,31 @@ describe('Run coordinator Orca session address', () => { expect(addressesFor(db, 'run_unbound')).toEqual([CHAT_ADDRESS]) }) + it('recreates on open a coordinator trigger an older build compiled with another address prefix', () => { + const path = tempDbPath(tempRoots) + db = new OrchestrationDb(path) + const triggers = db.db + .prepare(`SELECT sql FROM sqlite_master WHERE name LIKE 'trg_runs_remember_coordinator_%'`) + .all() + .map((row) => String(row.sql)) + expect(triggers).toHaveLength(2) + const olderBuild = triggers.map((sql) => + sql.replaceAll(`'${ORCA_SESSION_ADDRESS_PREFIX}'`, "'session:'") + ) + // Otherwise the reopen below would find current triggers and prove nothing. + olderBuild.forEach((sql, index) => expect(sql).not.toBe(triggers[index])) + db.db.exec(` + DROP TRIGGER trg_runs_remember_coordinator_insert; + DROP TRIGGER trg_runs_remember_coordinator_update; + ${olderBuild.map((sql) => `${sql};`).join('\n')} + `) + db.close() + + db = new OrchestrationDb(path) + insertSessionCoordinatedRun(db, 'run_after_upgrade') + expect(addressesFor(db, 'run_after_upgrade')).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) diff --git a/src/main/runtime/orchestration/structured-session-mail-address.ts b/src/main/runtime/orchestration/structured-session-mail-address.ts index 19a2a56a0ee7..a5dffbeb48a4 100644 --- a/src/main/runtime/orchestration/structured-session-mail-address.ts +++ b/src/main/runtime/orchestration/structured-session-mail-address.ts @@ -1,5 +1,5 @@ /** - * A structured agent session as a mail address: `session:`, the Orca-minted id every agent is + * A structured agent session as a mail address: `orca_session_id:`, the Orca-minted id every agent is * told is its public address. Recipient routing and pointer delivery both read these rules off the * durable session record, so the two can never disagree about which sessions mail can reach. * diff --git a/src/main/runtime/orchestration/structured-session-mail-target.test.ts b/src/main/runtime/orchestration/structured-session-mail-target.test.ts index 5ed9c1ce26f4..876c63312c50 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.test.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.test.ts @@ -171,14 +171,14 @@ describe('a Run whose coordinator is a chat (an Orca session id, no handle)', () }) describe('a session addressed directly', () => { - it('owns its `session:` mailbox', () => { + it('owns its `orca_session_id:` mailbox', () => { installStore(chatRecord()) expect(probe().target(CHAT_ADDRESS)).toEqual({ sessionId: CHAT, dispatchId: null }) }) it('claims nothing for a malformed session address', () => { installStore(chatRecord()) - expect(probe().target('session:term_abc')).toBeNull() + expect(probe().target('orca_session_id:term_abc')).toBeNull() }) }) @@ -438,7 +438,7 @@ describe('a coordinator chat continued by /clear', () => { orcaSessionId: CHAT, address: CHAT_ADDRESS }) - expect(probe().target(`session:${member}`)).toEqual({ + expect(probe().target(`orca_session_id:${member}`)).toEqual({ sessionId: SUCCESSOR, dispatchId: null }) diff --git a/src/main/runtime/orchestration/structured-session-mail-target.ts b/src/main/runtime/orchestration/structured-session-mail-target.ts index e71c08c512e4..73bec03c1a56 100644 --- a/src/main/runtime/orchestration/structured-session-mail-target.ts +++ b/src/main/runtime/orchestration/structured-session-mail-target.ts @@ -1,6 +1,6 @@ /** * Where a mailbox owned by a structured session is delivered: a chat that coordinates a Run - * (`run:` with no coordinator handle), a session addressed directly at `session:`, and the + * (`run:` with no coordinator handle), a session addressed directly at `orca_session_id:`, and the * live session behind a structured worker's handle. The session is resolved here, never a pane, and * takes the pointer as a session turn. */ @@ -76,7 +76,7 @@ export function structuredWorkerMailSessionId( } /** - * The target of a `session:` mailbox; `undefined` when the handle is not a session address at + * The target of an `orca_session_id:` mailbox; `undefined` when the handle is not a session address at * all, so other address forms keep their own resolution. */ export function structuredSessionAddressTarget( diff --git a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts index a22975c04548..d4ad22645eee 100644 --- a/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts +++ b/src/main/runtime/rpc/methods/orchestration/messaging/session-recipient.ts @@ -1,9 +1,9 @@ /** - * An agent session named as a recipient: `session:`, or a bare Orca session id. Any session on + * An agent session named as a recipient: `orca_session_id:`, or a bare Orca session id. Any session on * this host can be addressed, not only one that coordinates a Run: an agent's id is its public * address, and a user telling one agent to message another's id is a supported workflow. * - * Mail that no Run or Dispatch owns is stored at the conversation's `session:` and pointed + * Mail that no Run or Dispatch owns is stored at the conversation's `orca_session_id:` and pointed * at its live session as a turn, so any session of a `/clear` lineage is a valid spelling. A * released lease is not a refusal (the pointer's send starts its agent); a closed chat, another host, * and an unknown id are, before anything is stored. @@ -53,7 +53,7 @@ export function readSessionRecipient( ? { sessionId, address: formatOrcaSessionAddress(sessionId) } : { code: CODES.unknown, - message: `${recipient} does not name an Orca agent session id. No message was sent.` + message: `${recipient} does not name an Orca session ID. No message was sent.` } } const sessionId = isOrcaSessionId(recipient) ? recipient : null @@ -113,6 +113,6 @@ export function refuseUndeliverableSessionRecipient( function providerIdRefusal(id: string, orcaSessionId: string): SessionRecipientRefusal { return { code: CODES.providerId, - message: `${id} is the provider's own session id, which changes on /clear. This session's Orca address is ${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}; use that instead. No message was sent.` + message: `${id} is the provider's own session id, which changes on /clear. This session's Orca session ID is ${ORCA_SESSION_ADDRESS_PREFIX}${orcaSessionId}; address it by that instead. No message was sent.` } } diff --git a/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts b/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts index ab7588bf7d3b..36ff0a8cd092 100644 --- a/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts +++ b/src/main/runtime/rpc/methods/orchestration/worker/worker-abandon-caller.test.ts @@ -58,9 +58,9 @@ describe('orchestration.workerAbandon', () => { it('records the resolved Orca session as the one who abandoned the worker', async () => { const dispatchId = readyWorker() - const orcaSessionId = parseOrcaSessionAddress('session:chat_1')! + const orcaSessionId = parseOrcaSessionAddress('orca_session_id:chat_1')! const session: OrchestrationSessionCaller = { - address: 'session:chat_1', + address: 'orca_session_id:chat_1', terminalHandle: null, paneKey: null, orcaSessionId, @@ -72,7 +72,9 @@ describe('orchestration.workerAbandon', () => { orchestrationCaller: session, orchestrationCompatibilityEvidence: { terminalHandle: 'term_coord' } }) - expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe('Abandoned by session:chat_1.') + expect(db.getWorkerDispatch(dispatchId)?.last_error).toBe( + 'Abandoned by orca_session_id:chat_1.' + ) }) it('reports an already-settled worker as stale and changes nothing', async () => { diff --git a/src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts b/src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts new file mode 100644 index 000000000000..5239415ee96a --- /dev/null +++ b/src/main/runtime/rpc/orchestration-legacy-session-address-migration.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { OrchestrationDb } from '../orchestration/db' +import { migrateV43 } from '../orchestration/db/schema/migrate-v43' +import { + ADDRESS_X, + ADDRESS_Y, + createSessionCallerHarness, + orchestrationRequest, + resultOf, + SESSION_X, + SESSION_Y, + WORKER_HANDLE, + type SessionCallerHarness +} from './orchestration-session-caller-test-fixture' + +const hostRef = vi.hoisted((): { current: unknown } => ({ current: null })) +vi.mock('../../native-chat/agent-session-wire/structured-agent-session-registry', () => ({ + getStructuredAgentSessionHost: () => hostRef.current +})) + +const LEGACY_X = `session:${SESSION_X}` +const LEGACY_Y = `session:${SESSION_Y}` +// Remembered under both spellings; a session of its own, so routing to Y stays unambiguous. +const BOTH_SESSION = '5c5c5c5c-6d6d-4e7e-8f8f-909090909090' + +/** What an earlier build, which spelled a session's address `session:`, left in a v42 database. */ +function seedEarlierBuildRows(db: OrchestrationDb): { toX: string; fromX: string } { + // Subject and body spell the old address too: text, not an address, so it must survive as written. + const toX = db.insertMessage({ + from: WORKER_HANDLE, + to: ADDRESS_X, + subject: LEGACY_X, + body: LEGACY_X + }) + const fromX = db.insertMessage({ from: ADDRESS_X, to: WORKER_HANDLE, subject: 'q', body: 'b' }) + db.db.prepare('UPDATE messages SET to_handle = ? WHERE id = ?').run(LEGACY_X, toX.id) + db.db.prepare('UPDATE messages SET from_handle = ? WHERE id = ?').run(LEGACY_X, fromX.id) + db.db + .prepare( + `INSERT INTO runs (id, objective, consumer_generation, legacy) VALUES + ('run_earlier', 'coordinated by Y', 1, 0), ('run_both', 'remembered twice', 1, 0)` + ) + .run() + const remember = db.db.prepare( + 'INSERT INTO run_coordinator_handles (run_id, terminal_handle) VALUES (?, ?)' + ) + remember.run('run_earlier', LEGACY_Y) + remember.run('run_both', `session:${BOTH_SESSION}`) + remember.run('run_both', `orca_session_id:${BOTH_SESSION}`) + const pointer = db.db.prepare( + `INSERT INTO structured_pointer_operations + (mailbox_handle, session_id, operation_id, batch_fingerprint, minted_at_ms) + VALUES (?, ?, ?, 'batch', 1)` + ) + pointer.run(LEGACY_X, SESSION_X, 'op_legacy_x') + pointer.run(LEGACY_Y, SESSION_Y, 'op_legacy_y') + pointer.run(ADDRESS_Y, SESSION_Y, 'op_current_y') + db.createCoordinatorRun({ spec: 'spec', coordinatorHandle: LEGACY_X }) + db.db.pragma('user_version = 42') + return { toX: toX.id, fromX: fromX.id } +} + +function snapshot(db: OrchestrationDb): unknown[] { + return [ + 'SELECT id, from_handle, to_handle, subject, body FROM messages ORDER BY id', + 'SELECT run_id, terminal_handle FROM run_coordinator_handles ORDER BY run_id, terminal_handle', + 'SELECT mailbox_handle, operation_id FROM structured_pointer_operations ORDER BY mailbox_handle', + 'SELECT coordinator_handle FROM coordinator_runs' + ].map((sql) => db.db.prepare(sql).all()) +} + +describe('a database an earlier build wrote session: addresses into', () => { + let h: SessionCallerHarness + let seeded: { toX: string; fromX: string } + + beforeEach(() => { + h = createSessionCallerHarness(hostRef) + hostRef.current = { + deps: { + store: { + getRecord: (sessionId: string) => h.records.get(sessionId) ?? null, + listRecords: () => [...h.records.values()], + getVisibleSessionTabIndex: () => ({ present: true, sessionIds: [SESSION_X, SESSION_Y] }) + } + } + } + seeded = seedEarlierBuildRows(h.db) + h.db.migrate() + }) + + afterEach(() => { + h.close() + vi.restoreAllMocks() + }) + + it('gives the chat the mail sent to it before the upgrade, through its own check', async () => { + const checked = resultOf( + await h.dispatch(orchestrationRequest('orchestration.check', {}, { sessionId: SESSION_X })) + ) + expect(checked.messages).toEqual([ + expect.objectContaining({ + id: seeded.toX, + to_handle: ADDRESS_X, + subject: LEGACY_X, + body: LEGACY_X + }) + ]) + }) + + it('still routes mail to a chat into the Run it was remembered for under the old spelling', async () => { + const sent = await h.dispatch( + orchestrationRequest('orchestration.send', { + from: WORKER_HANDLE, + to: ADDRESS_Y, + subject: 's' + }) + ) + expect(sent).toMatchObject({ ok: true, result: { message: { to_handle: 'run:run_earlier' } } }) + }) + + it('refuses the old spelling as an unknown terminal, even for the Run once remembered under it', async () => { + const stored = h.db.getInbox(100).length + for (const legacy of [LEGACY_X, LEGACY_Y]) { + const sent = await h.dispatch( + orchestrationRequest('orchestration.send', { + from: WORKER_HANDLE, + to: legacy, + subject: 's' + }) + ) + expect(sent).toMatchObject({ + ok: false, + error: { + code: 'terminal_not_found', + message: `Terminal ${legacy} has no live pane or durable Run/Dispatch mailbox.` + } + }) + } + expect(h.db.getInbox(100)).toHaveLength(stored) + }) + + it('files a reply to mail the chat sent before the upgrade at its current address', async () => { + expect(h.db.getMessageById(seeded.fromX)?.from_handle).toBe(ADDRESS_X) + const replied = await h.dispatch( + orchestrationRequest('orchestration.reply', { + id: seeded.fromX, + body: 'a', + from: WORKER_HANDLE + }) + ) + expect(replied).toMatchObject({ ok: true, result: { message: { to_handle: ADDRESS_X } } }) + }) + + it('leaves no address in the old spelling, keeping the current row where both were keyed', () => { + const [messages, remembered, pointers, coordinatorRuns] = snapshot(h.db) + expect(JSON.stringify([messages, remembered, pointers, coordinatorRuns])).not.toMatch( + /"(from_handle|to_handle|terminal_handle|mailbox_handle|coordinator_handle)":"session:/ + ) + expect(remembered).toEqual([ + { run_id: 'run_both', terminal_handle: `orca_session_id:${BOTH_SESSION}` }, + { run_id: 'run_earlier', terminal_handle: ADDRESS_Y } + ]) + expect(pointers).toEqual([ + { mailbox_handle: ADDRESS_X, operation_id: 'op_legacy_x' }, + { mailbox_handle: ADDRESS_Y, operation_id: 'op_current_y' } + ]) + expect(coordinatorRuns).toEqual([{ coordinator_handle: ADDRESS_X }]) + }) + + it('changes nothing when it runs again', () => { + const before = snapshot(h.db) + migrateV43.call(h.db, 42) + expect(snapshot(h.db)).toEqual(before) + }) +}) diff --git a/src/main/runtime/rpc/orchestration-party-addressing.test.ts b/src/main/runtime/rpc/orchestration-party-addressing.test.ts index 5c281583f0a8..cbd8a25fdf1e 100644 --- a/src/main/runtime/rpc/orchestration-party-addressing.test.ts +++ b/src/main/runtime/rpc/orchestration-party-addressing.test.ts @@ -312,7 +312,7 @@ describe('no writer stores a structured worker under its session address', () => .prepare( `SELECT m.id FROM messages AS m JOIN dispatch_contexts AS d ON d.assignee_orca_session_id IS NOT NULL - AND 'session:' || d.assignee_orca_session_id IN (m.to_handle, m.from_handle)` + AND 'orca_session_id:' || d.assignee_orca_session_id IN (m.to_handle, m.from_handle)` ) .all() expect(h.db.getInbox(100).length).toBeGreaterThanOrEqual(7) diff --git a/src/main/runtime/rpc/orchestration-session-caller.test.ts b/src/main/runtime/rpc/orchestration-session-caller.test.ts index d6ab1ba3ccb6..d6dc81540c1a 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.test.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.test.ts @@ -263,7 +263,7 @@ describe('orchestration session callers at the dispatch entry', () => { ok: false, error: { code: CODES.providerId, - message: expect.stringContaining(`This session's Orca id is ${SESSION_X}`), + message: `${PROVIDER_ID_X} is the provider's own session id, which changes on /clear. This session's Orca session ID is orca_session_id:${SESSION_X}; set ORCA_AGENT_SESSION_ID=${SESSION_X} instead. No effects were applied.`, data: { orcaSessionId: SESSION_X, effectsApplied: false } } }) @@ -400,7 +400,7 @@ describe('orchestration session callers at the dispatch entry', () => { ).run expect(h.db.getRunRaw(idOf(run))?.coordinator_orca_session_id).toBe(SESSION_X) - const stranger = 'session:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' + const stranger = 'orca_session_id:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' const refused = await h.dispatch( orchestrationRequest( 'orchestration.runCreate', @@ -442,7 +442,7 @@ describe('orchestration session callers at the dispatch entry', () => { ) expect(resultOf(listed)).toMatchObject({ runId }) - const stranger = 'session:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' + const stranger = 'orca_session_id:0b5e2d7c-9a41-4c3e-8f62-7d1a3e5b9c08' const refused = await h.dispatch( orchestrationRequest(method, { run: runId, [param]: stranger }, { sessionId: SESSION_Y }) ) diff --git a/src/main/runtime/rpc/orchestration-session-caller.ts b/src/main/runtime/rpc/orchestration-session-caller.ts index 0d2253481b44..63412d4554be 100644 --- a/src/main/runtime/rpc/orchestration-session-caller.ts +++ b/src/main/runtime/rpc/orchestration-session-caller.ts @@ -12,12 +12,17 @@ * - A live lease: released, mid owner change or unreconciled sessions cannot act. * - The session wins over any declared caller: a declared handle must name this same session, and * a structured worker's session id maps to the handle and pane it was minted. - * - A request with no session id that declares a `session:` caller gets the party it names: a + * - A request with no session id that declares an `orca_session_id:` caller gets the party it names: a * worker's handle, or a refusal for a chat, whose address alone identifies nobody. */ import { agentSessionLeaseAdmitsWriter } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { isOrcaSessionId, parseOrcaSessionAddress } from '../../../shared/orca-session-address' +import { ORCA_AGENT_SESSION_ID_ENV } from '../../../shared/agent-session-caller-env' +import { + ORCA_SESSION_ADDRESS_PREFIX, + isOrcaSessionId, + parseOrcaSessionAddress +} from '../../../shared/orca-session-address' import { ORCHESTRATION_SESSION_CALLER_ERROR_CODES as CODES } from '../../../shared/orchestration-session-caller-codes' import type { OrcaRuntimeService } from '../orca-runtime' import type { OrchestrationSessionCaller } from '../orchestration/orchestration-caller-identity' @@ -74,7 +79,7 @@ export type ResolvedOrchestrationRequest = { const NO_EFFECTS = { effectsApplied: false } as const /** - * Whether this request names its caller by a session id, or declares a `session:` address as its + * Whether this request names its caller by a session id, or declares an `orca_session_id:` address as its * caller. Synchronous, so every other request, terminal callers included, takes no extra async hop. */ export function needsOrchestrationCallerResolution(request: RpcRequest): boolean { @@ -171,7 +176,7 @@ async function readSessionRecord( if (found.kind === 'provider-id') { throw new OrchestrationError( CODES.providerId, - `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca id is ${found.orcaSessionId}; use that instead. No effects were applied.`, + `${sessionId} is the provider's own session id, which changes on /clear. This session's Orca session ID is ${ORCA_SESSION_ADDRESS_PREFIX}${found.orcaSessionId}; set ${ORCA_AGENT_SESSION_ID_ENV}=${found.orcaSessionId} instead. No effects were applied.`, { ...NO_EFFECTS, orcaSessionId: found.orcaSessionId } ) } @@ -206,7 +211,7 @@ function assertSessionCanAct(sessionId: string, record: AgentSessionRecord): voi ) } -/** A request with no session id that declares a `session:` caller: a worker's is its handle. */ +/** A request with no session id that declares an `orca_session_id:` caller: a worker's is its handle. */ function bindDeclaredSessionAddress(db: OrchestrationDb, request: RpcRequest): RpcRequest { const name = ORCHESTRATION_CALLER_PARAM[request.method] const declared = declaredSessionAddress(request) diff --git a/src/main/runtime/rpc/orchestration-session-recipient.test.ts b/src/main/runtime/rpc/orchestration-session-recipient.test.ts index f8f2de266d48..415ff42ba322 100644 --- a/src/main/runtime/rpc/orchestration-session-recipient.test.ts +++ b/src/main/runtime/rpc/orchestration-session-recipient.test.ts @@ -95,11 +95,11 @@ describe('a send addressed to an agent session', () => { it.each([ [ 'an unknown session', - () => `session:0b0b0b0b-1111-4222-8333-444444444444`, + () => `orca_session_id:0b0b0b0b-1111-4222-8333-444444444444`, 'session_caller_unknown' ], - ['a malformed session address', () => 'session:term_abc', 'session_caller_unknown'], - ['a provider id', () => `session:${PROVIDER_ID_X}`, 'session_caller_provider_id'], + ['a malformed session address', () => 'orca_session_id:term_abc', 'session_caller_unknown'], + ['a provider id', () => `orca_session_id:${PROVIDER_ID_X}`, 'session_caller_provider_id'], ['a bare provider id', () => PROVIDER_ID_X, 'session_caller_provider_id'] ])('refuses %s before storing anything', async (_label, to, code) => { const sent = await send(to()) @@ -107,9 +107,29 @@ describe('a send addressed to an agent session', () => { expect(h.db.getInbox(100)).toEqual([]) }) + it.each([ + ['a provider id', () => `orca_session_id:${PROVIDER_ID_X}`], + ['a bare provider id', () => PROVIDER_ID_X] + ])('hands back the Orca session address to paste instead of %s', async (_label, to) => { + expect(errorMessage(await send(to()))).toBe( + `${PROVIDER_ID_X} is the provider's own session id, which changes on /clear. This session's Orca session ID is orca_session_id:${SESSION_X}; address it by that instead. No message was sent.` + ) + }) + + it('refuses the retired session: spelling like any unknown terminal handle', async () => { + expect(await send(`session:${SESSION_X}`)).toMatchObject({ + ok: false, + error: { + code: 'terminal_not_found', + message: `Terminal session:${SESSION_X} has no live pane or durable Run/Dispatch mailbox.` + } + }) + expect(h.db.getInbox(100)).toEqual([]) + }) + it('refuses a session on another host', async () => { h.records.set(SESSION_Y, sessionRecord(SESSION_Y, { location: { executionHostId: 'ssh:box' } })) - expect(await send(`session:${SESSION_Y}`)).toMatchObject({ + expect(await send(`orca_session_id:${SESSION_Y}`)).toMatchObject({ ok: false, error: { code: 'session_caller_host_boundary' } }) @@ -118,7 +138,7 @@ describe('a send addressed to an agent session', () => { it('refuses a session whose chat was closed, naming why', async () => { visible = [SESSION_X] - const sent = await send(`session:${SESSION_Y}`) + const sent = await send(`orca_session_id:${SESSION_Y}`) expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) expect(errorMessage(sent)).toContain('its chat was closed') expect(h.db.getInbox(100)).toEqual([]) @@ -140,7 +160,7 @@ describe('a send addressed to an agent session', () => { maxDepth: Number.MAX_SAFE_INTEGER }) structuredWorkerIdentities.clear() - const sent = await send(`session:${SESSION_Y}`) + const sent = await send(`orca_session_id:${SESSION_Y}`) expect(sent).toMatchObject({ ok: false, error: { code: 'session_caller_not_live' } }) expect(errorMessage(sent)).toContain('worker identity') }) @@ -193,7 +213,7 @@ describe('a live structured worker addressed by its session id', () => { } it('lands in its Dispatch mailbox, which its flagless check reads', async () => { - // The defect this pins: the mail was stored at `session:`, pointed at the worker, and its + // The defect this pins: the mail was stored at `orca_session_id:`, pointed at the worker, and its // `check` — which reads the worker's handle and Dispatch mailboxes — returned nothing. const run = h.db.createRun({ objective: 'pty coordinator', @@ -208,7 +228,7 @@ describe('a live structured worker addressed by its session id', () => { creator: { kind: 'system' }, maxDepth: Number.MAX_SAFE_INTEGER }) - expect(await sendTo(`session:${SESSION_Y}`)).toMatchObject({ + expect(await sendTo(`orca_session_id:${SESSION_Y}`)).toMatchObject({ message: { to_handle: `dispatch:${dispatch.id}` } }) expect(await flaglessCheck()).toMatchObject({ messages: [{ subject: 'hello' }] }) diff --git a/src/main/runtime/structured-chat-coordinator-mail.test.ts b/src/main/runtime/structured-chat-coordinator-mail.test.ts index e9606ea90cb1..7b2f0d2fe852 100644 --- a/src/main/runtime/structured-chat-coordinator-mail.test.ts +++ b/src/main/runtime/structured-chat-coordinator-mail.test.ts @@ -823,10 +823,10 @@ describe('a /clear keeps the chat its orchestration address', () => { await expect( call( 'orchestration.send', - { to: `session:${PEER_CHAT}`, subject: 'hi' }, + { to: `orca_session_id:${PEER_CHAT}`, subject: 'hi' }, { sessionId: successor } ) - ).resolves.toMatchObject({ message: { from_handle: `session:${COORDINATOR}` } }) + ).resolves.toMatchObject({ message: { from_handle: `orca_session_id:${COORDINATOR}` } }) }) it("binds a Run a cleared chat creates or uses to the conversation's root, at the Run's current generation", async () => { @@ -869,10 +869,10 @@ describe('a /clear keeps the chat its orchestration address', () => { for (const [index, spelling] of [PEER_CHAT, middle, successor].entries()) { const sent = await call('orchestration.send', { from: 'term_worker', - to: `session:${spelling}`, + to: `orca_session_id:${spelling}`, subject: `ping ${index}` }) - expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) + expect(sent).toMatchObject({ message: { to_handle: `orca_session_id:${PEER_CHAT}` } }) await vi.waitFor(() => expect(connectionFor(successor).turns).toHaveLength(index + 1), WAIT) await settleTurn(successor, index) } @@ -883,19 +883,19 @@ describe('a /clear keeps the chat its orchestration address', () => { }) describe('any live session is addressable by its id', () => { - it('lands mail sent to `session:` as a turn in that chat, which a flagless check reads', async () => { + it('lands mail sent to `orca_session_id:` as a turn in that chat, which a flagless check reads', async () => { const peer = await openChat(PEER_CHAT) const sent = await call('orchestration.send', { from: 'term_worker', - to: `session:${PEER_CHAT}`, + to: `orca_session_id:${PEER_CHAT}`, subject: 'ping' }) - expect(sent).toMatchObject({ message: { to_handle: `session:${PEER_CHAT}` } }) + expect(sent).toMatchObject({ message: { to_handle: `orca_session_id:${PEER_CHAT}` } }) await vi.waitFor(() => expect(peer.turns).toHaveLength(1), WAIT) // Direct mail is not in a Run, so the pointer names no `--run`. - expect(turnText(peer.turns[0]!)).toBe(ptyPointer(`session:${PEER_CHAT}`)) + expect(turnText(peer.turns[0]!)).toBe(ptyPointer(`orca_session_id:${PEER_CHAT}`)) await settleTurn(PEER_CHAT, 0) const checked = await call('orchestration.check', {}, { sessionId: PEER_CHAT }) expect(checked).toMatchObject({ count: 1, messages: [{ subject: 'ping' }] }) @@ -907,7 +907,7 @@ describe('any live session is addressable by its id', () => { const response = await dispatcher.dispatch( request('orchestration.send', { from: 'term_worker', - to: `session:${PEER_CHAT}`, + to: `orca_session_id:${PEER_CHAT}`, subject: 'ping' }) ) diff --git a/src/shared/agent-session-caller-env.ts b/src/shared/agent-session-caller-env.ts index 5f66128dc5f9..ca3d286614bf 100644 --- a/src/shared/agent-session-caller-env.ts +++ b/src/shared/agent-session-caller-env.ts @@ -21,7 +21,7 @@ export function readInjectedAgentSessionId( /** * The address the host gives this session (`mailboxAddressOf` on its resolved party): a structured - * worker keeps the handle it was minted, any other session is `session:`. Only for text that + * worker keeps the handle it was minted, any other session is `orca_session_id:`. Only for text that * must match what the host writes; the CLI spells it without the host resolver. */ export function injectedSessionAddress( diff --git a/src/shared/orca-session-address-prefix.ts b/src/shared/orca-session-address-prefix.ts index 984aec9d7890..64446301a64c 100644 --- a/src/shared/orca-session-address-prefix.ts +++ b/src/shared/orca-session-address-prefix.ts @@ -1,2 +1,2 @@ // A leaf with no imports, so the CLI entry can spell a session address without the codec's zod graph. -export const ORCA_SESSION_ADDRESS_PREFIX = 'session:' +export const ORCA_SESSION_ADDRESS_PREFIX = 'orca_session_id:' diff --git a/src/shared/orca-session-address.test.ts b/src/shared/orca-session-address.test.ts index 37ce908707c6..3feb1769b0e5 100644 --- a/src/shared/orca-session-address.test.ts +++ b/src/shared/orca-session-address.test.ts @@ -7,10 +7,10 @@ import { import { testOrcaSessionId } from './orca-session-address-test-fixture' const SESSION_ID = testOrcaSessionId('0b7e4c2a-5f1d-4e8a-9c3b-2d6f8a1e4b70') -const ADDRESS = `session:${SESSION_ID}` +const ADDRESS = `orca_session_id:${SESSION_ID}` describe('Orca session address', () => { - it('addresses an Orca session id as session: and parses the bare id back', () => { + it('addresses an Orca session id as orca_session_id: and parses the bare id back', () => { expect(formatOrcaSessionAddress(SESSION_ID)).toBe(ADDRESS) expect(parseOrcaSessionAddress(ADDRESS)).toBe(SESSION_ID) const parsed = parseOrcaSessionAddress(ADDRESS) @@ -30,14 +30,19 @@ describe('Orca session address', () => { ['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'], + ['an empty id', 'orca_session_id:'], + ['an id with a separator', `orca_session_id:${SESSION_ID}:extra`], + ['an id the session predicate rejects', 'orca_session_id:short'], ['a terminal handle', 'term_4f2c9a'] ])('refuses %s', (_label, value) => { expect(parseOrcaSessionAddress(value)).toBeNull() }) + it('no longer reads the retired session: spelling as a session, as an address or a bare id', () => { + expect(parseOrcaSessionAddress(`session:${SESSION_ID}`)).toBeNull() + expect(isOrcaSessionId(`session:${SESSION_ID}`)).toBe(false) + }) + it.each([ ['a PTY terminal handle', 'term_4f2c9a1b-7d3e-4a5f-8b6c-9d0e1f2a3b4c'], ['a short PTY terminal handle', 'term_4f2c9a'], @@ -45,7 +50,7 @@ describe('Orca session address', () => { ])('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() + expect(parseOrcaSessionAddress(`orca_session_id:${handle}`)).toBeNull() }) it('validates an Orca session id with the session-record predicate', () => { diff --git a/src/shared/orca-session-address.ts b/src/shared/orca-session-address.ts index d0098ea2ef9d..621afbfa3e34 100644 --- a/src/shared/orca-session-address.ts +++ b/src/shared/orca-session-address.ts @@ -6,7 +6,7 @@ import { ORCA_SESSION_ADDRESS_PREFIX } from './orca-session-address-prefix' * 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, * 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:`, + * session's. Mail addresses the session as `orca_session_id:`, 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 @@ -20,7 +20,7 @@ declare const orcaSessionAddressBrand: unique symbol /** A bare Orca session id; only `isOrcaSessionId` and `parseOrcaSessionAddress` produce one. */ export type OrcaSessionId = string & { readonly [orcaSessionIdBrand]: true } -/** A `session:` mail address; only `formatOrcaSessionAddress` produces one. */ +/** An `orca_session_id:` mail address; only `formatOrcaSessionAddress` produces one. */ export type OrcaSessionAddress = string & { readonly [orcaSessionAddressBrand]: true } // Terminal handles (`term_` from the PTY runtime, `structworker_` from structured-worker-identity) @@ -41,7 +41,7 @@ export function formatOrcaSessionAddress(orcaSessionId: OrcaSessionId): OrcaSess return address } -/** The bare Orca session id of a `session:` address; anything else reads as null. */ +/** The bare Orca session id of an `orca_session_id:` address; anything else reads as null. */ export function parseOrcaSessionAddress(address: string | null | undefined): OrcaSessionId | null { if (!address?.startsWith(ORCA_SESSION_ADDRESS_PREFIX)) { return null