diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 96079a165d45..1c39b3c8a8f1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -862,7 +862,6 @@ jobs: tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts - tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: diff --git a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts new file mode 100644 index 000000000000..120588b306dd --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts @@ -0,0 +1,227 @@ +// Version 4: the chat records join the journal database, copied in by the migration's own +// transaction, so "copied" is exactly `user_version >= 4`. + +import { existsSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + journalPragmaNumber, + NO_LEGACY_JOURNAL_RECORDS, + openJournalDatabase, + readJournalDatabaseVersion, + type JournalLegacyRecordImport +} from './journal-database' +import { createJournalTablesSql } from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' + +let root: string +let dbPath: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-records-migration-')) + dbPath = journalDatabasePath(root) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** What a build from before the records moved left: the released version-3 shape. */ +function seedVersion3(): void { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + db.exec(createJournalTablesSql()) + db.prepare( + "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s1', 'ws', 'e1')" + ).run() + db.pragma('user_version = 3') + db.close() +} + +/** A copy of one record row, counting how many times the migration ran it. */ +function importOf(sessionId: string): JournalLegacyRecordImport & { runs: number } { + const copy = { + owed: false as const, + runs: 0, + write: (db: Database.Database) => { + copy.runs += 1 + db.prepare( + 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ).run(sessionId, '{}') + } + } + return copy +} + +function inspect(read: (db: Database.Database) => T): T { + const db = new Database(dbPath) + try { + return read(db) + } finally { + db.close() + } +} + +const recordIds = (db: Database.Database): string[] => + db + .prepare('SELECT session_id FROM agent_session_records ORDER BY session_id') + .all() + .map((row) => String(row.session_id)) + +const hasTable = (db: Database.Database, name: string): boolean => + db + .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name) !== undefined + +describe('the version-4 migration', () => { + it('moves a version-3 database to 4 with its rows and the copied records', () => { + seedVersion3() + const copy = importOf('session-a') + + const opened = openJournalDatabase(dbPath, copy) + opened.db.close() + + expect(opened).toMatchObject({ readOnly: false, legacyRecordImportOwed: false }) + expect(copy.runs).toBe(1) + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['session-a']) + expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toEqual({ epoch: 'e1' }) + }) + }) + + it('never runs the copy again once the database is at 4', () => { + seedVersion3() + openJournalDatabase(dbPath, importOf('session-a')).db.close() + const again = importOf('session-b') + + openJournalDatabase(dbPath, again).db.close() + + expect(again.runs).toBe(0) + inspect((db) => expect(recordIds(db)).toEqual(['session-a'])) + }) + + it('leaves version 3 and no record rows when it dies inside the migration, then copies once', () => { + seedVersion3() + const original = Database.prototype.pragma + const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( + this: Database.Database, + sql: string, + options?: { simple?: boolean } + ) { + if (sql === 'user_version = 4') { + throw new Error('crash before the version is published') + } + return original.call(this, sql, options) + }) + + expect(() => openJournalDatabase(dbPath, importOf('session-a'))).toThrow( + 'crash before the version is published' + ) + pragma.mockRestore() + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(3) + expect(hasTable(db, 'agent_session_records')).toBe(false) + }) + + const retry = importOf('session-a') + openJournalDatabase(dbPath, retry).db.close() + expect(retry.runs).toBe(1) + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['session-a']) + }) + }) + + it('makes the tables but keeps the copy owed when the records file could not be read', () => { + seedVersion3() + + const opened = openJournalDatabase(dbPath, { owed: true }) + // A chat created while the copy is owed keeps its row through the copy that follows. + opened.db + .prepare("INSERT INTO agent_session_records (session_id, record_json) VALUES ('new', '{}')") + .run() + opened.db.close() + + expect(opened.legacyRecordImportOwed).toBe(true) + inspect((db) => expect(journalPragmaNumber(db, 'user_version')).toBe(3)) + openJournalDatabase(dbPath, importOf('session-a')).db.close() + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['new', 'session-a']) + }) + }) + + it('stamps a fresh file with the released version while the copy is owed', () => { + openJournalDatabase(dbPath, { owed: true }).db.close() + + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(3) + expect(hasTable(db, 'journal_rows')).toBe(true) + expect(hasTable(db, 'agent_session_records')).toBe(true) + }) + }) + + // An install's first probe must not leave an empty database that reads as a profile with chats. + it('reads a missing database as version 0 without creating it', () => { + expect(readJournalDatabaseVersion(dbPath)).toBe(0) + expect(existsSync(dbPath)).toBe(false) + }) + + it('creates every table on a fresh file at version 4', () => { + openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db.close() + + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + for (const table of [ + 'journal_rows', + 'agent_session_records', + 'agent_session_operations', + 'agent_session_retired_claim_keys', + 'agent_session_tabs', + 'agent_session_store_meta' + ]) { + expect(hasTable(db, table)).toBe(true) + } + }) + }) +}) + +/** + * The open of a build released before the records moved (version 3), pinned here: it latches any + * higher version read-only. Its records file stays its own and writable, so nothing it does can + * reach the records this database holds. + */ +function openAsVersion3Build(path: string): { db: Database.Database; readOnly: boolean } { + const probe = new Database(path) + const stored = journalPragmaNumber(probe, 'user_version') + if (stored > 3) { + probe.close() + return { db: new Database(path, { readonly: true, fileMustExist: true }), readOnly: true } + } + return { db: probe, readOnly: false } +} + +describe('a build from before the move', () => { + it('opens a version-4 database read-only, so it never writes beside the records', () => { + openJournalDatabase(dbPath, importOf('session-a')).db.close() + + const older = openAsVersion3Build(dbPath) + try { + expect(older.readOnly).toBe(true) + expect(() => + older.db + .prepare( + "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s2', 'ws', 'e')" + ) + .run() + ).toThrow(/readonly/i) + } finally { + older.db.close() + } + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index ffad529cb4b4..e110b9221c44 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -6,12 +6,16 @@ // for a rebuild a partial repair leaves behind (see journal-repair-marker.ts). `journal_imports` // records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and // `journal_set_aside` each chat whose per-chat file is not this build's history and is never read -// again. +// again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the +// retired claim keys and the chat tab index (agent-session-record-rows.ts). /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version * (`JournalRow.v`): a newer build can change either alone. A newer version opens read-only here, - * so every change stays additive. Versions below 3 were never released (journal-database.ts). */ -export const JOURNAL_DB_SCHEMA_VERSION = 3 + * so every change stays additive. */ +export const JOURNAL_DB_SCHEMA_VERSION = 4 + +/** The first version a release wrote; 1 and 2 only ever came from development builds. */ +export const JOURNAL_DB_OLDEST_RELEASED_VERSION = 3 export function createJournalTablesSql(): string { return ` @@ -46,3 +50,31 @@ CREATE TABLE IF NOT EXISTS journal_set_aside ( ); ` } + +/** Version 4: the chat records, until then a JSON file beside the database. Each row is one JSON + * value as the file held it, so a row this build cannot read stays byte-identical. */ +export function createAgentSessionRecordTablesSql(): string { + return ` +CREATE TABLE IF NOT EXISTS agent_session_records ( + session_id TEXT PRIMARY KEY, + record_json TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_operations ( + operation_key TEXT PRIMARY KEY, + row_json TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_retired_claim_keys ( + key_id TEXT PRIMARY KEY, + retired_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_tabs ( + tab_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + position INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_store_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +` +} diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts index 0b479cd9adf6..677efc21c4d7 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts @@ -8,6 +8,7 @@ import { JOURNAL_BUSY_TIMEOUT_MS, JOURNAL_SIZE_LIMIT_BYTES, journalPragmaNumber, + NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' @@ -66,7 +67,7 @@ afterEach(async () => { describe('the host journal database open', () => { it('creates every table and reads back every load-bearing pragma', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") @@ -97,14 +98,14 @@ describe('the host journal database open', () => { // T5: a newer build's database opens read-only, its rows readable, and is left byte-identical. it('opens a newer user_version read-only without touching the file', async () => { - const seeded = openJournalDatabase(dbPath).db + const seeded = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') insertJournalRow(seeded, 'session-1', epochRow(1)) seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) seeded.close() const before = await digest(dbPath) - const opened = openJournalDatabase(dbPath) + const opened = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS) try { expect(opened.readOnly).toBe(true) expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1') @@ -121,7 +122,7 @@ describe('the host journal database open', () => { it('closes the raw connection when schema setup throws', async () => { const failing = join(root, 'nested', 'agent-session-journal.db') - expect(() => openJournalDatabase(failing)).toThrow() + expect(() => openJournalDatabase(failing, NO_LEGACY_JOURNAL_RECORDS)).toThrow() await expect(stat(`${failing}-wal`)).rejects.toThrow() await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) @@ -130,7 +131,7 @@ describe('the host journal database open', () => { describe('journal row statements', () => { it('serves replay, resume, suffix truncation and an epoch discard', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { @@ -161,7 +162,7 @@ describe('journal row statements', () => { }) it('deletes only the rows no pointer names', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying')) insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live')) @@ -180,7 +181,7 @@ describe('journal row statements', () => { }) it('refuses a duplicate sequence inside one epoch of one chat', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { insertJournalRow(db, 'session-1', epochRow(1)) expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() @@ -192,7 +193,7 @@ describe('journal row statements', () => { }) it('moves the epoch pointer in place', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { publishJournalSessionEpoch(db, SESSION, 'epoch-1') publishJournalSessionEpoch(db, SESSION, 'epoch-2') @@ -220,7 +221,9 @@ describe('schema creation', () => { return original.call(this, sql, options) }) - expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published') + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + 'crash before the version is published' + ) pragma.mockRestore() const inspected = new Database(dbPath) @@ -250,8 +253,12 @@ INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) earlier.close() const before = await digest(dbPath) - expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) - expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + `unreleased schema ${version}` + ) + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + JournalUnreleasedSchemaError + ) expect(await digest(dbPath)).toBe(before) await expect(stat(`${dbPath}-wal`)).rejects.toThrow() diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 2c01a653af38..a306ad6e564e 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -4,9 +4,15 @@ // no DDL: a database written by a newer schema must be left byte-identical, and // `journal_mode = WAL` writes the file header. +import { existsSync } from 'node:fs' import Database from '../../sqlite/sync-database' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' -import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { + createAgentSessionRecordTablesSql, + createJournalTablesSql, + JOURNAL_DB_OLDEST_RELEASED_VERSION, + JOURNAL_DB_SCHEMA_VERSION +} from './journal-database-schema' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { ensureQueuedMessagesTable } from './queued-message-schema' @@ -20,13 +26,80 @@ export type OpenJournalDatabase = { db: Database.Database /** A newer `user_version` was met: this build reads and never writes. */ readOnly: boolean + /** The chat records file could not be read this launch: version 4's copy of it is still owed. */ + legacyRecordImportOwed: boolean +} + +/** + * What version 4's migration copies in from the chat records file that preceded it, read before + * the open so no transaction waits on a file read. `owed` is a read that can clear: the tables are + * made, and the copy and the version bump wait for a launch whose read succeeds. + */ +export type JournalLegacyRecordImport = + | { owed: true } + | { owed: false; write: (db: Database.Database) => void } + +export const NO_LEGACY_JOURNAL_RECORDS: JournalLegacyRecordImport = { + owed: false, + write: () => undefined } export function journalPragmaNumber(db: Database.Database, name: string): number { return Number(db.pragma(name, { simple: true }) ?? 0) } -export function openJournalDatabase(dbPath: string): OpenJournalDatabase { +/** Whether an open of a database at `stored` runs version 4's migration, and so reads the records + * file first. */ +export function journalDatabaseMigratesRecords(stored: number): boolean { + return ( + stored === 0 || + (stored >= JOURNAL_DB_OLDEST_RELEASED_VERSION && stored < JOURNAL_DB_SCHEMA_VERSION) + ) +} + +/** 0 for a database not created yet: the probe never creates the file. */ +export function readJournalDatabaseVersion(dbPath: string): number { + if (!existsSync(dbPath)) { + return 0 + } + const probe = new Database(dbPath) + try { + return journalPragmaNumber(probe, 'user_version') + } finally { + probe.close() + } +} + +/** + * Whether the database holds any chat record or tab, read-only. `undefined` while version 4's copy + * of the records file is still owed, so the file answers for the chats it holds. + */ +export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | undefined { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map(({ name }) => name) + ) + const holds = ['agent_session_records', 'agent_session_tabs'].some( + (table) => + tables.has(table) && db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() !== undefined + ) + if (holds || journalPragmaNumber(db, 'user_version') >= JOURNAL_DB_SCHEMA_VERSION) { + return holds + } + return undefined + } finally { + db.close() + } +} + +export function openJournalDatabase( + dbPath: string, + legacyRecords: JournalLegacyRecordImport +): OpenJournalDatabase { const probe = new Database(dbPath) let stored: number try { @@ -37,25 +110,29 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { } if (stored > JOURNAL_DB_SCHEMA_VERSION) { probe.close() - return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), readOnly: true } + return { + db: new Database(dbPath, { readonly: true, fileMustExist: true }), + readOnly: true, + legacyRecordImportOwed: false + } } let transferred = false try { - if (stored !== 0 && stored < JOURNAL_DB_SCHEMA_VERSION) { + if (stored !== 0 && stored < JOURNAL_DB_OLDEST_RELEASED_VERSION) { // No retry reads past it, so every chat says it can't load; the log says what to do. throw new JournalUnreleasedSchemaError( - `chat journal ${dbPath} uses unreleased schema ${stored}, written by an unreleased development build of Orca; move the file aside and Orca starts a new one` + `chat journal ${dbPath} uses unreleased schema ${stored}, written by an unreleased development build of Orca. Orca can't load its chats. Moving the file aside lets Orca start a new one, but that loses every chat's history, tabs and ownership records stored in it.` ) } configureJournalPragmas(probe, stored) - createJournalSchema(probe, stored) - // Outside `createJournalSchema` on purpose: its early return skips a db + const legacyRecordImportOwed = migrateJournalSchema(probe, stored, legacyRecords) + // Outside `migrateJournalSchema` on purpose: its early return skips a db // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true - return { db: probe, readOnly: false } + return { db: probe, readOnly: false, legacyRecordImportOwed } } finally { if (!transferred) { probe.close() @@ -82,17 +159,35 @@ function configureJournalPragmas(db: Database.Database, stored: number): void { } /** - * Table creation and the `user_version` bump are ONE transaction. Creating the tables first left - * a shaped database still reporting version 0, which an older build does not latch read-only. + * Table creation, the records copy and the `user_version` bump are ONE transaction. Creating the + * tables first left a shaped database still reporting version 0, which an older build does not + * latch read-only; and "copied" is `user_version >= 4`, so no other marker can disagree with it. + * Returns whether the copy is still owed. */ -function createJournalSchema(db: Database.Database, stored: number): void { +function migrateJournalSchema( + db: Database.Database, + stored: number, + legacyRecords: JournalLegacyRecordImport +): boolean { if (stored >= JOURNAL_DB_SCHEMA_VERSION) { - return + return false } runJournalTransaction(db, () => { - db.exec(createJournalTablesSql()) + if (stored === 0) { + db.exec(createJournalTablesSql()) + } + db.exec(createAgentSessionRecordTablesSql()) + if (legacyRecords.owed) { + // A fresh file still takes a released version, so an older build opens it as one. + if (stored === 0) { + db.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) + } + return + } + legacyRecords.write(db) db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`) }) + return legacyRecords.owed } /** diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index 9ce0e0c34319..dbe15b4709ed 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -3,6 +3,7 @@ // same directory reads the same database the way a restarted host would. import { resolve } from 'node:path' +import { NO_LEGACY_JOURNAL_RECORDS } from './journal-database' import { JournalHostDatabase } from './journal-host-database' import { replayJournal, type JournalLoad } from './journal-open' import { serializeJournalRow, type JournalRow } from './journal-row-schema' @@ -25,11 +26,18 @@ export function openTestJournalHostDatabase(stateDirectory: string): JournalHost if (existing && !existing.isClosed) { return existing } - const database = JournalHostDatabase.open(directory) + const database = JournalHostDatabase.openWith(directory, NO_LEGACY_JOURNAL_RECORDS) opened.set(directory, database) return database } +/** Closes this directory's database, so the next open reads the file as a fresh process would. */ +export function closeTestJournalHostDatabase(stateDirectory: string): void { + const directory = resolve(stateDirectory) + opened.get(directory)?.close() + opened.delete(directory) +} + /** Closes every database this process opened for tests. */ export function closeTestJournalHostDatabases(): void { for (const database of opened.values()) { diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index b6999e4ef559..ca6036d3a9d0 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -10,8 +10,12 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session- import type Database from '../../sqlite/sync-database' import { JOURNAL_SYNCHRONOUS, + journalDatabaseMigratesRecords, + NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase, + readJournalDatabaseVersion, runJournalTransaction, + type JournalLegacyRecordImport, type OpenJournalDatabase } from './journal-database' import { journalOpenRefusalError } from './journal-open-failure' @@ -28,6 +32,8 @@ export class JournalHostDatabase { private connection: Database.Database | null /** A newer Orca wrote the database: every chat's history reads, and no chat writes. */ readonly readOnly: boolean + /** The chat records file could not be read this launch, so its copy waits for a later one. */ + readonly legacyRecordImportOwed: boolean /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false @@ -37,13 +43,33 @@ export class JournalHostDatabase { ) { this.connection = opened.db this.readOnly = opened.readOnly + this.legacyRecordImportOwed = opened.legacyRecordImportOwed } - static open(stateDirectory: string): JournalHostDatabase { + /** `readLegacyRecords` runs only when this open migrates to version 4, before any transaction. */ + static async open( + stateDirectory: string, + readLegacyRecords: () => Promise + ): Promise { + mkdirSync(stateDirectory, { recursive: true }) + const migrates = journalDatabaseMigratesRecords( + readJournalDatabaseVersion(journalDatabasePath(stateDirectory)) + ) + return JournalHostDatabase.openWith( + stateDirectory, + migrates ? await readLegacyRecords() : NO_LEGACY_JOURNAL_RECORDS + ) + } + + /** The same open with the records file already read; tests pass `NO_LEGACY_JOURNAL_RECORDS`. */ + static openWith( + stateDirectory: string, + legacyRecords: JournalLegacyRecordImport + ): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) return new JournalHostDatabase( stateDirectory, - openJournalDatabase(journalDatabasePath(stateDirectory)) + openJournalDatabase(journalDatabasePath(stateDirectory), legacyRecords) ) } diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 2e9e21d024f4..2fb343d2d508 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { openJournalDatabase } from './journal-database' +import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, @@ -28,7 +28,7 @@ afterEach(async () => { /** What the journal's own open, then a chat's replay, throws for the file as it stands. */ function openFailure(): unknown { try { - const db = openJournalDatabase(journalDatabasePath(root)).db + const db = openJournalDatabase(journalDatabasePath(root), NO_LEGACY_JOURNAL_RECORDS).db try { replayJournal(db, 'session-1') } finally { @@ -59,7 +59,7 @@ describe('classifyJournalOpenFailure', () => { it('calls a journal whose pages are damaged corrupt', async () => { const path = journalDatabasePath(root) - const opened = openJournalDatabase(path).db + const opened = openJournalDatabase(path, NO_LEGACY_JOURNAL_RECORDS).db opened.exec('PRAGMA journal_mode = DELETE') opened.close() const bytes = await readFile(path) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 7cfd7fe86234..f209a333e971 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -119,7 +119,8 @@ describe('Claude root-exit stop', () => { const deps = { store, adapter, - journalDatabase: openTestJournalHostDatabase(root), + // The one database the store and the journal share, as the runtime installs them. + journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1' } const runtimeState = new StructuredAgentSessionHostRuntimeState(deps) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts index a4069875bade..78f4ab1beb5c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts @@ -5,10 +5,9 @@ // // What it cannot skip is work already in flight, which is what every copy of this helper used to // get wrong. Two producers reach the session store after the last awaited call has returned — a -// lease-renewal tick, and the restart the delivery loop wakes for an accepted send — and the store -// re-creates its own directory before every commit. A commit landing after the test removed its -// temp directory therefore puts that directory back, and the removal fails with ENOTEMPTY. Quit -// waits for both, in its `stop-lease-renewal` and `drain-attaches` phases; so does this. +// lease-renewal tick, and the restart the delivery loop wakes for an accepted send — and a commit +// landing after the test's cleanup meets a journal database already closed. Quit waits for both, +// in its `stop-lease-renewal` and `drain-attaches` phases; so does this. import type { StructuredAgentSessionHost } from './structured-agent-session-host' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 1a701f5b13cc..20a76aabfd3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -1,5 +1,4 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,6 +19,7 @@ import type { } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { TrackedTestRecoveryCapsule } from './structured-agent-session-host-test-recovery-capsule' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -58,6 +58,7 @@ const attachParams = ( const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence) let root: string +let recoveryCapsule: TrackedTestRecoveryCapsule let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock @@ -149,11 +150,12 @@ beforeEach(async () => { answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) store = await openTestAgentSessionRecordStore(root) + recoveryCapsule = new TrackedTestRecoveryCapsule(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), - recoveryCapsule: new AgentSessionRecoveryCapsule(root), + recoveryCapsule, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -163,9 +165,16 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() await journals.closeAll() + // A host a test replaced can still hold the capsule's lock directory under `root`. + await recoveryCapsule.settled() await rm(root, { recursive: true, force: true }) }) +/** Waits out every recovery-capsule operation a host of this test started, replaced ones too. */ +function hostTestRecoveryCapsuleSettled(): Promise { + return recoveryCapsule.settled() +} + /** A restarted process swaps the store and the host under the same directories. * The helpers here close over both, so they have to be told. */ export function replaceHostTestState(next: { @@ -201,6 +210,7 @@ export { attachParams, ensureParams, envelope, + hostTestRecoveryCapsuleSettled, journals, seedApproval } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts new file mode 100644 index 000000000000..0319346a9ab1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts @@ -0,0 +1,66 @@ +// The host harness's recovery capsule. A host a test replaced keeps its restart-offer lane running +// (a withdrawal is fire-and-forget), and nothing else waits for it, so cleanup waits here before it +// removes the directory whose lock that operation holds. + +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' + +type Capsule = AgentSessionRecoveryCapsule + +export class TrackedTestRecoveryCapsule extends AgentSessionRecoveryCapsule { + private readonly running = new Set>() + + /** Until no operation runs, including one a finished operation's lane starts next. */ + async settled(): Promise { + do { + await Promise.allSettled(this.running) + await new Promise((resolve) => setImmediate(resolve)) + } while (this.running.size > 0) + } + + override list(...args: Parameters) { + return this.track(super.list(...args)) + } + + override listFailed(...args: Parameters) { + return this.track(super.listFailed(...args)) + } + + override record(...args: Parameters) { + return this.track(super.record(...args)) + } + + override beginResume(...args: Parameters) { + return this.track(super.beginResume(...args)) + } + + override completeResume(...args: Parameters) { + return this.track(super.completeResume(...args)) + } + + override failResume(...args: Parameters) { + return this.track(super.failResume(...args)) + } + + override dismiss(...args: Parameters) { + return this.track(super.dismiss(...args)) + } + + override forgetSuperseded(...args: Parameters) { + return this.track(super.forgetSuperseded(...args)) + } + + override rollbackResume(...args: Parameters) { + return this.track(super.rollbackResume(...args)) + } + + override clearAll(...args: Parameters) { + return this.track(super.clearAll(...args)) + } + + private track(operation: Promise): Promise { + this.running.add(operation) + const forget = () => this.running.delete(operation) + operation.then(forget, forget) + return operation + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index bcda340ee79e..15fb58f58924 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -216,6 +216,8 @@ export class StructuredAgentSessionHost { getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) showSessionTabs = (sessionIds: readonly string[]) => this.deps.store.showSessionTabs(sessionIds) + /** The records file could not be read this launch, so chats it holds are not listed yet. */ + legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true setSessionTabVisibility = async ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index 3fb865f50ec7..ac4bb5e2f4d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -3,39 +3,57 @@ import { readdir } from 'node:fs/promises' import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' -import { JournalHostDatabase } from '../agent-session-journal/journal-host-database' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { attach, CALLER, envelope, + hostTestRecoveryCapsuleSettled, hostTestState } from './structured-agent-session-host-test-harness' import { hostTestMessage } from './structured-agent-session-host-test-data' let root: string +let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let cancelTurn: Mock beforeEach(() => { - ;({ root, host, cancelTurn } = hostTestState()) + ;({ root, store, host, cancelTurn } = hostTestState()) }) afterEach(() => vi.restoreAllMocks()) +const sqliteError = (message: string, errcode: number): Error => + Object.assign(new Error(message), { code: 'ERR_SQLITE_ERROR', errcode }) + +/** Every statement on the chat records' tables fails; the history beside them still works. */ +function failRecordStatements(error: Error): void { + const connection = openTestJournalHostDatabase(root).db + const prepare = connection.prepare.bind(connection) + vi.spyOn(connection, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('agent_session_')) { + throw error + } + return prepare(sql) + }) +} + +const stop = (turnEnvelope = envelope('agentSession.cancel', { turnId: 'turn-1' })) => + host.cancel(CALLER, { envelope: turnEnvelope, turnId: 'turn-1' }) + // T-corrupt-midsession. it('refuses a send as corrupt when SQLite reports damage, and still stops the agent', async () => { await attach() const files = await readdir(root, { recursive: true }) - const damaged = Object.assign(new Error('database disk image is malformed'), { - code: 'ERR_SQLITE_ERROR', - errcode: 11 - }) - vi.spyOn(JournalHostDatabase.prototype, 'transaction').mockImplementation(() => { + const damaged = sqliteError('database disk image is malformed', 11) + vi.spyOn(openTestJournalHostDatabase(root), 'transaction').mockImplementation(() => { throw damaged }) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const body = hostTestMessage('after the damage') const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) @@ -48,14 +66,55 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag details: { reason: 'journalCorrupt' } } }) - // Stop reaches the agent before it writes anything; the note it then cannot record is the - // error the caller sees, after the fact. - await expect( - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - }) - ).rejects.toBe(damaged) + // Stop reaches the agent without its ledger row; the note it then cannot record is the error + // the caller sees, after the fact. + await expect(stop()).rejects.toBe(damaged) expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + sessionId: expect.any(String), + error: 'database disk image is malformed' + }) + // The restart-offer withdrawal the attach started holds its lock until it ends. + await hostTestRecoveryCapsuleSettled() expect(await readdir(root, { recursive: true })).toEqual(files) }) + +it.each([ + ['damaged', sqliteError('database disk image is malformed', 11)], + ['full', sqliteError('database or disk is full', 13)] +])('stops the agent when the records are %s and the history is not', async (_, error) => { + await attach() + failRecordStatements(error) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(stop()).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { turnId: 'turn-1', cancelled: true } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + sessionId: expect.any(String), + error: error.message + }) +}) + +it('replays a recorded Stop from memory when its ledger cannot be written', async () => { + await attach() + const stopEnvelope = envelope('agentSession.cancel', { turnId: 'turn-1' }) + await expect(stop(stopEnvelope)).resolves.toMatchObject({ ok: true, replayed: false }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + // A replay writes nothing on a healthy store; one that refuses every transaction still throws. + vi.spyOn(store, 'admitMutationOperation').mockRejectedValue( + sqliteError('database disk image is malformed', 11) + ) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // Interrupting twice would stop a turn the client never asked to stop. + await expect(stop(stopEnvelope)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { turnId: 'turn-1', cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index a61e11b6f4f1..21d790202709 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -36,8 +36,8 @@ export class StructuredAgentSessionLeaseRenewer { } /** Clearing the interval only stops the NEXT tick. A tick already past its guard still has a - * store transaction to commit, and that transaction re-creates the store directory, so a stop - * that returned before it landed would let the write outlive whatever tore the host down. */ + * store transaction to commit, so a stop that returned before it landed would let the write + * outlive whatever tore the host down. */ stop(): Promise { if (this.timer) { clearInterval(this.timer) @@ -91,8 +91,8 @@ export class StructuredAgentSessionLeaseRenewer { now }) } - // The store persists the whole record file per transaction, so keep the healthy path to - // one commit. If one renewal is superseded, retrying individually preserves isolation. + // One transaction for every renewal on the healthy path. If one renewal is superseded, + // retrying individually preserves isolation. let results: PromiseSettledResult[] try { const renewed = await this.input.store.renewLeases(renewals) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 6a623428e4f0..3e96a02ce26a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -21,8 +21,17 @@ import { type AgentSessionMutationResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' +import type { + AgentSessionMutationOperationAdmission, + AgentSessionMutationOperationDecision +} from '../../runtime/agent-session-operation-admission' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + classifyJournalOpenFailure, + journalOpenRefusal +} from '../agent-session-journal/journal-open-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { MutationPlan } from './structured-agent-session-mutation-plans' @@ -104,14 +113,32 @@ export async function admitAndRunAgentSessionMutation( if (!journal) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } - const admitted = await request.store.admitMutationOperation({ + const operation: AgentSessionMutationOperationAdmission = { callerKey: request.callerKey, envelope, hostFingerprint, now: request.now(), ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), ...(plan.conversationWrite ? { conversationWrite: true } : {}) - }) + } + let admitted: AgentSessionMutationOperationDecision + let ledgerRowWritten = true + try { + admitted = await request.store.admitMutationOperation(operation) + } catch (error) { + if (plan.runsWithoutLedgerRow) { + admitted = admitWithoutLedgerRow(request.store, operation, error) + ledgerRowWritten = false + } else if ( + isAgentSessionRefusalError(error) || + classifyJournalOpenFailure(error) === 'journalCorrupt' + ) { + // A store refusing the row (a newer Orca's records) or damage SQLite proves: as an open says. + return refuseAgentSessionMutation(journalOpenRefusal(error)) + } else { + throw error + } + } if (!admitted) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } @@ -152,20 +179,47 @@ export async function admitAndRunAgentSessionMutation( } } - const outcome = await runSettledAgentSessionMutation({ - store: request.store, - // A global send replay can cross caller identities. Settlement still owns - // the durable row admitted by the original caller. - operationCallerKey: admission.row.callerKey, - envelope, - plan, - context - }) + // With no row, a settle fails on the same storage and turns a landed Stop into a throw. + const outcome = ledgerRowWritten + ? await runSettledAgentSessionMutation({ + store: request.store, + // A global send replay can cross caller identities. Settlement still owns + // the durable row admitted by the original caller. + operationCallerKey: admission.row.callerKey, + envelope, + plan, + context + }) + : await plan.run(context) return outcome.ok ? { ok: true, replayed: false, fence, cursor: journal.cursor(), value: outcome.value } : refuseAgentSessionMutation(outcome.refusal) } +/** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ +function admitWithoutLedgerRow( + store: AgentSessionRecordStore, + operation: AgentSessionMutationOperationAdmission, + error: unknown +): AgentSessionMutationOperationDecision { + console.warn("[agent-session] Stop's ledger row skipped:", { + sessionId: operation.envelope.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + const evaluated = store.evaluateMutationOperation(operation) + if (!evaluated) { + return null + } + const admission = admitAgentSessionMutation({ + envelope: operation.envelope, + hostFingerprint: operation.hostFingerprint, + ledger: evaluated.decision, + lease: evaluated.record.lease, + ...(operation.conversationWrite ? { conversationWrite: true } : {}) + }) + return { admission, record: evaluated.record } +} + function turnContext( request: AgentSessionMutationRequest, journal: AgentSessionJournal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 388fabd7bd45..7eecbd50db93 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -48,6 +48,8 @@ export type MutationPlan = { operationIdScope?: 'global' /** Admitted without the writer lease: see `admitAgentSessionMutation`. */ conversationWrite?: true + /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ + runsWithoutLedgerRow?: true markUnknownBeforeRun?: boolean run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null @@ -176,6 +178,8 @@ export function cancelPlan(params: { method: 'agentSession.cancel', // Stop is a conversation write; a prompt or background-task cancel needs the live child. ...(params.scope || params.prompt ? {} : { conversationWrite: true as const }), + // A Stop must reach the agent even when storage refuses the row recording it. + runsWithoutLedgerRow: true, fields: { ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index bc4697be6df8..26eaa8b8ab59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -286,9 +286,10 @@ describe('Send-now rerun', () => { expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) ) // The host died before the Send's answer settled: its ledger row is still pending, so it reruns. - for (const row of store['transactions'].state.operations.values()) { + const operations = store['transactions'].state.operations + for (const [key, row] of operations) { if (row.operationId === operationId) { - row.outcome = { status: 'pending' } + operations.set(key, { ...row, outcome: { status: 'pending' } }) } } const count = (await host.journalSnapshot(SESSION)).submissions.length diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index f349f252ebed..41dd7913d85a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -11,7 +11,7 @@ import { classifyStoreFailure } from './structured-agent-session-attach' const MAX_RECONCILIATION_PASSES = 8 -/** Adjudicates leases loaded by this process or refreshed from another writer. +/** Adjudicates leases loaded by this process. * Answers with the refusal attach owes its caller, or null once settled. */ export function createRestartReconciler(deps: { store: AgentSessionRecordStore diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 0cffa1e32c75..4da648576ed6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -165,7 +165,7 @@ describe('restart journal restoration', () => { expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored']) }) - // Each failed bookkeeping call stands for one wait on a held store lock. + // Each failed bookkeeping call stands for one refused store write. describe('once lease bookkeeping fails in a pass', () => { const records = Array.from( { length: 8 }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 2d868797024c..17bb4b0ee1fc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type * as DurableFileWrite from '../../durable-file-write' +import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { @@ -31,21 +31,16 @@ const CALLER = { callerKey: 'client-1' } const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) -vi.mock('../../durable-file-write', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - renameDurable: async (tmpPath: string, finalPath: string) => { - if (finalPath.endsWith('agent-sessions.json')) { - publishFault.publishCount += 1 - } - if ( - finalPath.endsWith('agent-sessions.json') && - publishFault.publishCount === publishFault.failOnPublish - ) { + writeAgentSessionStoreRows: (...args: Parameters) => { + publishFault.publishCount += 1 + if (publishFault.publishCount === publishFault.failOnPublish) { throw new Error('simulated crash before failed-settlement publish') } - return actual.renameDurable(tmpPath, finalPath) + return actual.writeAgentSessionStoreRows(...args) } } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 1d7b6621b3ef..d4f8bce00332 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -1,18 +1,17 @@ -// The lease reconcile is bookkeeping: a lock that gives up or a store this build may not write is -// reported, and startup and every read carry on. Nothing is owed after it, because an unreconciled -// lease grants no writer and the next send reconciles every lease again before it acts. +// The lease reconcile is bookkeeping: a write that fails is reported, and startup and every read +// carry on. Nothing is owed after it, because an unreconciled lease grants no writer and the next +// send reconciles every lease again before it acts. Over records a newer Orca wrote, the reconcile +// adjudicates in memory and writes nothing. import { cp, readdir, readFile, rm } from 'node:fs/promises' -import { dirname } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import type * as FileTransactionLock from '../../file-transaction-lock' -import { AGENT_SESSION_STORE_SCHEMA_VERSION } from '../../runtime/agent-session-record-store-file' +import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' import { - editPersistedTestAgentSessionStore, openTestAgentSessionRecordStore, - type PersistedTestAgentSessionStore, - testAgentSessionStoreFilePath + seedTestAgentSessionStoreFromNewerBuild } from '../../runtime/agent-session-record-store-test-harness' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -32,34 +31,37 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -const lock = vi.hoisted(() => ({ failing: false })) +const writes = vi.hoisted(() => ({ failing: false })) -vi.mock('../../file-transaction-lock', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - withFileTransactionLock: (...args: Parameters) => - lock.failing - ? // What proper-lockfile throws once its retries give up. - Promise.reject( - Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) - ) - : actual.withFileTransactionLock(...args) + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing) { + // What SQLite throws when the disk will not take the commit. + throw Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) + } + return actual.writeAgentSessionStoreRows(...args) + } } }) +const IO_ERROR = expect.objectContaining({ message: 'disk I/O error' }) + const relaunchedRoots: string[] = [] afterEach(async () => { - lock.failing = false + writes.failing = false await Promise.all( relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) ) }) -/** A host with one chat, relaunched over a copy of its files; `rewrite` edits the copied store. */ +/** A host with one chat, relaunched over a copy of its files; `newer` marks the copied records as + * a newer Orca's. */ async function relaunch( - rewrite?: (persisted: PersistedTestAgentSessionStore) => void, + newer = false, probeOwner: StructuredAgentSessionHostDeps['probeOwner'] = async () => ({ outcome: 'pid-absent' }) @@ -75,9 +77,8 @@ async function relaunch( recursive: true, filter: (source) => !source.includes('.lock') }) - const storePath = testAgentSessionStoreFilePath(relaunched) - if (rewrite) { - await editPersistedTestAgentSessionStore(relaunched, rewrite) + if (newer) { + await seedTestAgentSessionStoreFromNewerBuild(relaunched) } const store = await openTestAgentSessionRecordStore(relaunched) const onLeaseReconcileFailure = vi.fn() @@ -95,27 +96,27 @@ async function relaunch( onLeaseReconcileFailure }) replaceHostTestState({ store, host }) - return { host, store, storePath, onLeaseReconcileFailure } + return { host, store, stateDirectory: relaunched, onLeaseReconcileFailure } } it('reports a startup reconcile whose store write fails, and does not reject', async () => { const { host, store, onLeaseReconcileFailure } = await relaunch() - lock.failing = true + writes.failing = true await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) // Nothing was adjudicated, so the lease still grants no writer. expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) }) it('reconciles the chat on its next send once the store can be written again', async () => { const { host, store, onLeaseReconcileFailure } = await relaunch() - lock.failing = true + writes.failing = true await host.reconcileRestartLeases() expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - lock.failing = false + writes.failing = false const body = hostTestMessage('sent after a startup reconcile failed') await expect( @@ -132,28 +133,27 @@ it('reconciles the chat on its next send once the store can be written again', a await host.flushAllStreamedEvents() }) -it('reports a store a newer Orca wrote without writing it, and does not reject', async () => { - const { host, store, storePath, onLeaseReconcileFailure } = await relaunch((persisted) => { - persisted.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 - }) +it('adjudicates records a newer Orca wrote in memory only, and writes nothing', async () => { + const { host, store, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) expect(store.readOnly).toBe(true) - const bytes = await readFile(storePath) - const files = await readdir(dirname(storePath)) + const path = journalDatabasePath(stateDirectory) + const bytes = await readFile(path) + const files = await readdir(stateDirectory) await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith( - expect.objectContaining({ message: 'agent_session_legacy_required' }) - ) - expect(store.listRecords().map((record) => record.sessionId)).toEqual([SESSION]) - expect(await readFile(storePath)).toEqual(bytes) - expect(await readdir(dirname(storePath))).toEqual(files) + expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + unreconciled: false, + claimStatus: 'released' + }) + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(stateDirectory)).toEqual(files) }) it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => { const { host, store, onLeaseReconcileFailure } = await relaunch() - lock.failing = true + writes.failing = true await host.reconcileRestartLeases() await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() @@ -161,38 +161,54 @@ it('restores a chat for reading while the reconcile keeps failing, and reports i expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) }) -it('restores a chat for reading from a store a newer Orca wrote', async () => { - const { host, storePath, onLeaseReconcileFailure } = await relaunch((persisted) => { - persisted.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 +it('refuses a send over records a newer Orca wrote with the update words', async () => { + const { host } = await relaunch(true) + await host.reconcileRestartLeases() + + const body = hostTestMessage('sent to a chat a newer Orca saved') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } }) - const bytes = await readFile(storePath) +}) + +it('restores a chat for reading from records a newer Orca wrote', async () => { + const { host, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) + const path = journalDatabasePath(stateDirectory) + const bytes = await readFile(path) await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(true) - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(await readFile(storePath)).toEqual(bytes) + expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(await readFile(path)).toEqual(bytes) }) // A chat whose owner could not be proven gone is left recovering; the next attach or send retries it. it('restores a chat for reading when resolving its recovery cannot write the store', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch(undefined, async () => ({ + const { host, store, onLeaseReconcileFailure } = await relaunch(false, async () => ({ outcome: 'indeterminate', reason: 'probe' })) await host.reconcileRestartLeases() expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') - lock.failing = true + writes.failing = true await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) }) it.each([ @@ -205,14 +221,14 @@ it.each([ throw sinkError }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - lock.failing = true + writes.failing = true try { await expect(read(host)).resolves.toBeUndefined() expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() expect(warn).toHaveBeenCalledWith( expect.stringContaining('reporting a lease bookkeeping failure failed'), - expect.objectContaining({ failure: expect.objectContaining({ code: 'ELOCKED' }), sinkError }) + expect.objectContaining({ failure: IO_ERROR, sinkError }) ) } finally { warn.mockRestore() diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts index dc0fd835fa89..adbdf162cccf 100644 --- a/src/main/runtime/agent-session-backup-recovery-fence.ts +++ b/src/main/runtime/agent-session-backup-recovery-fence.ts @@ -1,14 +1,14 @@ -// Recovering the agent-session store from its backup, without minting a second writer. +// Importing the records file's backup, without minting a second writer. Only the one-time import +// into the chat database (agent-session-legacy-record-import.ts) applies this now. // // The backup is the previous committed generation. The commit that never landed may have granted a // fence chosen by `nextAgentSessionFence` from the backup lease, and // `isAgentSessionFenceCurrent` compares with STRICT EQUALITY. That choice may already be above // `runtimeFence + 1` after an earlier recovery; the new floor must strictly dominate it. // -// The bound is one lost mint per backup generation: each mint site uses -// `nextAgentSessionFence` once per transaction, and the save path aborts rather than advancing the -// primary past a stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected -// mint is not caught. +// The bound is one lost mint per backup generation: each mint site used `nextAgentSessionFence` +// once per transaction, and the file's save path aborted rather than advancing the primary past a +// stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected mint is not caught. // // This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current // fence is what an earlier version did, and it corrupted exactly the records it meant to save: a @@ -19,8 +19,7 @@ // Ownership is deliberately untouched. `claimStatus` (a conflict must survive restart), // `ownerProcess` (the identity evidence the owner probe needs — the lease owner is a child process // that can outlive a main-process crash) and `handoffStage` all carry forward verbatim. Loading -// already marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe -// once transactions are admitted. Nulling that evidence is how you get two writers on one provider +// marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe. Nulling that evidence is how you get two writers on one provider // session; the fence protects the store, not the provider session. import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts deleted file mode 100644 index 3c6ac2dcecf8..000000000000 --- a/src/main/runtime/agent-session-backup-recovery.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { evaluateAgentSessionAcquisition } from '../../shared/agent-session-lease-adjudication' -import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' -import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { - agentSessionStorePath, - loadAgentSessionStore, - saveAgentSessionStore -} from './agent-session-record-store-file' - -/** Reserve, observe the spawn, prove the handle: the only path to a `live` lease, whose invariant - * is that the head handle was minted at exactly the current fence. One store instance throughout, - * because reopening marks every lease unreconciled and refuses the next two steps. */ -async function seedLiveSession(sessionId: string): Promise { - const store = await openStore() - const reserved = await store.reserveOwner({ - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - expectedFence: null, - spawnToken: 'seed-live', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed-live' }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId, - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: 'seed-live' - }, - now: NOW - }) - await store.proveOwner({ - sessionId, - fence, - link: { - linkId: 'link-live-1', - origin: 'created', - mintedAtFence: fence, - observedAt: NOW, - handle: { provider: 'codex', threadId: `thread-${sessionId}` } - }, - now: NOW - }) - return fence -} - -let root: string -let storePath: string - -const NOW = 1_800_000_000_000 - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-')) - storePath = agentSessionStorePath(root) - operations = 0 -}) - -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) - -async function openStore(): Promise { - return AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) -} - -let operations = 0 - -function operationId(): string { - operations += 1 - return `${NOW}-${operations.toString(16).padStart(32, '0')}` -} - -async function seedSession(sessionId: string): Promise { - const store = await openStore() - const reserved = await store.reserveOwner({ - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - expectedFence: null, - spawnToken: 'seed', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed' }, - now: NOW - }) - return reserved.record.lease.runtimeFence -} - -describe('crash-safe store writes', () => { - it('never leaves the live path absent, and keeps a whole backup', async () => { - await seedSession('session-a') - const afterFirst = await readFile(storePath, 'utf-8') - expect(JSON.parse(afterFirst)).toBeTruthy() - - await seedSession('session-b') - // Both candidates parse: the previous generation was COPIED aside, not moved. - expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() - expect(JSON.parse(await readFile(`${storePath}.bak`, 'utf-8'))).toBeTruthy() - }) - - it('leaves no orphaned temp file behind', async () => { - await seedSession('session-a') - await seedSession('session-b') - const { readdir } = await import('node:fs/promises') - expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) - }) - - it('aborts the save rather than advancing the primary past a stale backup', async () => { - await seedSession('session-a') - const committed = await readFile(storePath, 'utf-8') - const loaded = await loadAgentSessionStore(storePath, 'local') - // A directory in the backup's place makes the rotation fail the way a full or read-only - // disk would. The save must not publish a primary the backup can no longer match. - await rm(`${storePath}.bak`, { force: true }) - const { mkdir } = await import('node:fs/promises') - await mkdir(`${storePath}.bak`) - - await expect( - saveAgentSessionStore(storePath, loaded.state, { - primaryStatus: 'validated' - }) - ).rejects.toBeTruthy() - expect(await readFile(storePath, 'utf-8')).toBe(committed) - expect((await stat(`${storePath}.bak`)).isDirectory()).toBe(true) - }) -}) - -describe('recovery from the committed backup', () => { - it('completes the next transaction instead of refusing forever', async () => { - await seedSession('session-a') - await seedSession('session-b') - // The exact shape a real profile wedged in: backup only, no live file. - await rm(storePath, { force: true }) - - await expect(seedSession('session-c')).resolves.toBeGreaterThan(0) - expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() - }) - - it('never grants the fence the lost commit could already have handed out', async () => { - const fence = await seedSession('session-a') - // A second commit is what produces the backup; the first write has nothing to rotate. - await seedSession('session-b') - await rm(storePath, { force: true }) - - const store = await openStore() - // The floor lands in the first transaction after recovery, not at load. - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const record = store.getRecord('session-a') - expect(record).toBeTruthy() - - // The lost commit could have granted fence + 1. Adjudication must skip it, or two writers - // end up holding the same number under strict-equality comparison. - const granted = evaluateAgentSessionAcquisition({ - lease: { ...record!.lease, unreconciled: false, ownerProcess: null, claimStatus: 'released' }, - expectedFence: record!.lease.runtimeFence, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - expect(granted.decision).toBe('granted') - expect(granted.decision === 'granted' && granted.nextFence).toBeGreaterThan(fence + 1) - }) - - it('does not reissue a grant after two backup fallbacks and a backup rotation', async () => { - await seedSession('session-a') - await seedSession('session-b') - const loaded = await loadAgentSessionStore(storePath, 'local') - const record = loaded.state.records.get('session-a') - if (!record) { - throw new Error('seeded session missing') - } - loaded.state.records.set('session-a', { - ...record, - lease: { - ...record.lease, - runtimeFence: 7, - claimStatus: 'released', - handoffStage: null, - reservedSpawnToken: null - } - }) - // Two commits put the prepared generation in the backup, just as normal rotation would. - await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) - await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) - const identity = { - location: record.location, - provider: record.provider, - accountHome: record.accountHome, - runtimeKind: record.lease.runtimeKind, - claimKeyId: record.lease.claimKeyId - } - - await rm(storePath, { force: true }) - const first = await openStore() - await first.retireClaimKey(`retire-${operationId()}`, NOW) - await first.reconcileOnRestart({ - probe: async () => ({ outcome: 'reservation-unused' }), - now: NOW - }) - expect(first.getRecord('session-a')?.lease).toMatchObject({ - runtimeFence: 7, - minimumNextFence: 9, - unreconciled: false - }) - const firstGrant = await first.reserveOwner({ - ...identity, - sessionId: 'session-a', - expectedFence: 7, - spawnToken: 'first-recovery', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'first-recovery' }, - now: NOW - }) - const firstFence = firstGrant.record.lease.runtimeFence - const rotated = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( - 'session-a' - ) - expect(rotated?.lease).toMatchObject({ runtimeFence: 7, minimumNextFence: 9 }) - - // The primary's grant is lost, but its owner may still hold that exact fence. - await rm(storePath, { force: true }) - const second = await openStore() - await second.retireClaimKey(`retire-${operationId()}`, NOW) - await second.reconcileOnRestart({ - probe: async () => ({ outcome: 'reservation-unused' }), - now: NOW - }) - const secondGrant = await second.reserveOwner({ - ...identity, - sessionId: 'session-a', - expectedFence: 7, - spawnToken: 'second-recovery', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'second-recovery' }, - now: NOW - }) - expect(secondGrant.record.lease.runtimeFence).toBeGreaterThan(firstFence) - expect((await openStore()).getRecord('session-a')?.lease.runtimeFence).toBe( - secondGrant.record.lease.runtimeFence - ) - }) - - it.each([ - [Number.MAX_SAFE_INTEGER - 2, Number.MAX_SAFE_INTEGER], - [Number.MAX_SAFE_INTEGER - 1, null] - ])('keeps the recovered floor safe at fence %i', async (runtimeFence, expectedFloor) => { - await seedSession('session-a') - await seedSession('session-b') - const backupPath = `${storePath}.bak` - const backup = JSON.parse(await readFile(backupPath, 'utf-8')) - backup.records['session-a'].lease.runtimeFence = runtimeFence - await writeFile(backupPath, JSON.stringify(backup)) - await rm(storePath, { force: true }) - - const recovered = await openStore() - if (expectedFloor === null) { - await expect(recovered.retireClaimKey(`retire-${operationId()}`, NOW)).rejects.toThrow( - 'agent_session_fence_exhausted' - ) - await expect(stat(storePath)).rejects.toMatchObject({ code: 'ENOENT' }) - const preserved = (await loadAgentSessionStore(backupPath, 'local')).state.records.get( - 'session-a' - )?.lease - expect(preserved?.runtimeFence).toBe(runtimeFence) - expect(preserved?.minimumNextFence).toBeUndefined() - } else { - await recovered.retireClaimKey(`retire-${operationId()}`, NOW) - expect(recovered.getRecord('session-a')?.lease).toMatchObject({ - runtimeFence, - minimumNextFence: expectedFloor - }) - expect((await openStore()).getRecord('session-a')?.lease.minimumNextFence).toBe(expectedFloor) - } - }) - - it('leaves recovered records valid, so the next load does not quarantine them', async () => { - await seedLiveSession('session-a') - await seedSession('session-b') - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const record = store.getRecord('session-a') - expect(record?.lease.claimStatus).toBe('live') - // A `live` lease means a provider handle proven at exactly lease.runtimeFence. Recovery that - // rewrote the fence broke that, so the record failed validation, was quarantined on the next - // load, and dropped straight back to the same backup. - expect(isPersistedAgentSessionRecord(record)).toBe(true) - }) - - it('carries ownership evidence forward verbatim', async () => { - await seedSession('session-a') - await seedSession('session-b') - const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( - 'session-a' - )! - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const after = store.getRecord('session-a')! - expect(after.lease.claimStatus).toBe(before.lease.claimStatus) - expect(after.lease.ownerProcess).toEqual(before.lease.ownerProcess) - expect(after.lease.handoffStage).toBe(before.lease.handoffStage) - // "Not currently owned" is expressed by unreconciled, not by erasing the evidence. - expect(after.lease.unreconciled).toBe(true) - }) - - // Recovery restores the previous COMMITTED generation; the lost commit is lost by definition. - // What must not happen is reconciliation dropping anything the backup did hold. - it('drops nothing the committed backup held', async () => { - await seedSession('session-a') - await seedSession('session-b') - const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const after = (await loadAgentSessionStore(storePath, 'local')).state - expect([...after.records.keys()].sort()).toEqual([...before.records.keys()].sort()) - expect(after.operations.size).toBe(before.operations.size) - // Everything the backup held is still there; the transaction that drove recovery adds its own. - for (const key of before.retiredClaimKeys) { - expect(after.retiredClaimKeys).toContainEqual(key) - } - expect([...after.unreadableRecords.keys()]).toEqual([...before.unreadableRecords.keys()]) - }) -}) - -describe('a transient primary read failure is not recovery', () => { - it('refuses rather than falling back to a usable but older backup', async () => { - await seedSession('session-a') - // The second commit leaves a VALID backup, so a fallback would silently succeed with - // older state — the failure this guard exists to prevent. - await seedSession('session-b') - expect( - (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.has('session-a') - ).toBe(true) - - // A directory at the primary path fails the read with EISDIR, not ENOENT — the shape of a - // permission or IO fault. It says nothing about the primary's contents. - await rm(storePath, { force: true }) - const { mkdir } = await import('node:fs/promises') - await mkdir(storePath) - - await expect(loadAgentSessionStore(storePath, 'local')).rejects.toThrow( - 'agent_session_store_corrupt' - ) - }) -}) - -describe('the fence-step bound the +2 floor rests on', () => { - it('advances a session fence by exactly one per grant when no floor is set', () => { - const granted = evaluateAgentSessionAcquisition({ - lease: agentSessionLeaseFixture({ - runtimeFence: 7, - claimStatus: 'released', - ownerProcess: null, - provenHandleLinkId: null - }), - expectedFence: 7, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - // If a grant could ever advance by more than one, the +2 recovery floor would stop dominating - // the highest fence a single lost commit can have handed out. - expect(granted.decision === 'granted' && granted.nextFence).toBe(8) - }) - - it('honours a recovery floor that sits above the next step', () => { - const granted = evaluateAgentSessionAcquisition({ - lease: agentSessionLeaseFixture({ - runtimeFence: 7, - minimumNextFence: 9, - claimStatus: 'released', - ownerProcess: null, - provenHandleLinkId: null - }), - expectedFence: 7, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - expect(granted.decision === 'granted' && granted.nextFence).toBe(9) - }) -}) - -describe('a store that never existed', () => { - it('does not claim recovery', async () => { - await writeFile(join(root, 'unrelated.txt'), 'x', 'utf-8') - const loaded = await loadAgentSessionStore(storePath, 'local') - expect(loaded.storeFound).toBe(false) - expect(loaded.recoveredFromBackup).toBe(false) - }) -}) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 41e8d300e1e9..0b5b6e5f2efd 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -19,7 +19,6 @@ import { import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionDeathEvidence, - AgentSessionJournalCheckpoint, AgentSessionLease, AgentSessionProcessIdentity, AgentSessionRecord @@ -270,26 +269,3 @@ function releasedAgentSessionLease( deathEvidence }) } - -export function setAgentSessionJournalCheckpoint(args: { - record: AgentSessionRecord - fence: number - checkpoint: AgentSessionJournalCheckpoint - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.fence) - const current = record.lease.journalCheckpoint - if ( - current && - (current.epoch > args.checkpoint.epoch || - (current.epoch === args.checkpoint.epoch && current.sequence > args.checkpoint.sequence)) - ) { - throw new Error('agent_session_checkpoint_stale') - } - return withLease(record, { - ...record.lease, - journalCheckpoint: args.checkpoint, - lastRenewedAt: args.now - }) -} diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts new file mode 100644 index 000000000000..da09cb2257d8 --- /dev/null +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -0,0 +1,440 @@ +// The one-time copy of `agent-sessions.json` into the chat journal database: what it copies, what it +// leaves untouched, and how each fence stays clear of anything the file's writers could have granted. + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import { + agentSessionOperationKey, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' + +const NOW = 1_800_000_000_000 +const ALPHA = 'session-alpha' +const BETA = 'session-beta' +const OPERATION_ID = `${NOW}-000000000000000000000000000000aa` +const LEGACY_TAB_ID = `structured-agent-session-${ALPHA}` + +let root: string +const opened: JournalHostDatabase[] = [] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-legacy-record-import-')) +}) + +afterEach(async () => { + opened.splice(0).forEach((database) => database.close()) + await rm(root, { recursive: true, force: true }) +}) + +const legacyPath = (): string => legacyAgentSessionStorePath(root) + +/** A released chat: no owner a restart probe must look for. */ +function record(sessionId: string, lease: Partial = {}): AgentSessionRecord { + return agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + runtimeFence: 2, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + ...lease + }) + ) +} + +const OPERATION: AgentSessionOperationRow = { + callerKey: 'client-1', + operationId: OPERATION_ID, + fingerprint: 'fp-1', + operationTimestamp: NOW, + recordedAt: NOW, + expiresAt: NOW + 60_000, + outcome: { status: 'succeeded', sessionId: ALPHA } +} + +function legacyFile( + records: readonly (Record & { sessionId: string })[], + extra: Record = {} +): Record { + return { + schemaVersion: 2, + hostId: 'local', + records: Object.fromEntries(records.map((value) => [value.sessionId, value])), + operations: {}, + retiredClaimKeys: [], + unusableRecords: {}, + ...extra + } +} + +async function writeLegacy(file: unknown, backup?: unknown): Promise { + await mkdir(dirname(legacyPath()), { recursive: true }) + await writeFile(legacyPath(), typeof file === 'string' ? file : JSON.stringify(file)) + if (backup !== undefined) { + await writeFile( + `${legacyPath()}.bak`, + typeof backup === 'string' ? backup : JSON.stringify(backup) + ) + } +} + +/** What an install does: open the database, running the copy if it is owed, then the store. */ +async function install(): Promise<{ + database: JournalHostDatabase + store: AgentSessionRecordStore + reports: LegacyAgentSessionRecordImportReport[] +}> { + const reports: LegacyAgentSessionRecordImportReport[] = [] + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + hostId: 'local', + onLegacyRecordImportReport: (report) => reports.push(report) + }) + opened.push(database) + return { + database, + store: AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' }), + reports + } +} + +function unreconciled(value: AgentSessionRecord): AgentSessionRecord { + return { ...value, lease: { ...value.lease, unreconciled: true } } +} + +describe('copying the records file into the chat database', () => { + it('copies every record, operation, key and tab, and leaves both files byte-identical', async () => { + const alpha = record(ALPHA) + const beta = record(BETA, { runtimeFence: 5 }) + const file = legacyFile([alpha, beta], { + operations: { [agentSessionOperationKey('client-1', OPERATION_ID)]: OPERATION }, + retiredClaimKeys: [{ keyId: 'key-0', retiredAt: NOW }], + sessionTabs: [{ tabId: 'tab-beta', sessionId: BETA }] + }) + await writeLegacy(file, file) + const [primary, backup] = await Promise.all([ + readFile(legacyPath()), + readFile(`${legacyPath()}.bak`) + ]) + + const { database, store, reports } = await install() + + expect(reports).toEqual([]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.getRecord(ALPHA)).toEqual(unreconciled(alpha)) + expect(store.getRecord(BETA)).toEqual(unreconciled(beta)) + expect(store.listOperationRows()).toEqual([OPERATION]) + expect(store.isClaimKeyVerifiable('key-0', NOW)).toBe(true) + expect(store.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [BETA] }) + expect(store.getSessionTabId(BETA)).toBe('tab-beta') + expect(await readFile(legacyPath())).toEqual(primary) + expect(await readFile(`${legacyPath()}.bak`)).toEqual(backup) + }) + + it('copies once: a later launch never reads the file again', async () => { + await writeLegacy(legacyFile([record(ALPHA)])) + const first = await install() + await first.store.setConversationName(ALPHA, 'kept') + first.database.close() + // A downgraded build changed its own file; the database keeps what it holds. + await writeLegacy(legacyFile([record(BETA)])) + + const { store } = await install() + + expect(store.getRecord(ALPHA)?.conversationName).toBe('kept') + expect(store.getRecord(BETA)).toBeNull() + }) + + it('reports a file no read will make usable, copies nothing, and leaves it untouched', async () => { + const adHoc = JSON.stringify({ ...legacyFile([record(ALPHA)]), schemaVersion: 1 }) + await writeLegacy(adHoc) + + const { database, store, reports } = await install() + + expect(reports).toEqual([ + { + kind: 'unusable', + error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) + } + ]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.listRecords()).toEqual([]) + expect(await readFile(legacyPath(), 'utf-8')).toBe(adHoc) + }) + + it('reports a file from another host id and copies it, so its owners stay unresolved', async () => { + await writeLegacy({ ...legacyFile([record(ALPHA)]), hostId: 'ssh:elsewhere' }) + + const { store, reports } = await install() + + expect(reports).toEqual([ + { kind: 'host-mismatch', fileHostId: 'ssh:elsewhere', hostId: 'local' } + ]) + expect(store.hostId).toBe('local') + expect(store.getRecord(ALPHA)).not.toBeNull() + }) + + it('maps a lease the removed terminal handoff wrote, and stores it mapped', async () => { + const legacy = { + ...record(ALPHA, { + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' } + }), + lease: { + ...record(ALPHA).lease, + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' }, + claimStatus: 'live', + runtimeKind: 'tui', + handoffStage: 'old-owner-stopped', + handoffOperationId: 'op-handoff', + processlessAt: 5 + } + } + await writeLegacy(legacyFile([legacy])) + + const { database, store } = await install() + + expect(store.isSessionUnreadable(ALPHA)).toBe(false) + const lease = store.getRecord(ALPHA)?.lease + expect(lease).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering', + handoffOperationId: 'op-handoff', + claimStatus: 'conflicted' + }) + expect(lease).not.toHaveProperty('processlessAt') + const stored = database.db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(ALPHA) + expect(JSON.parse(String(stored?.record_json)).lease).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering' + }) + expect(JSON.parse(String(stored?.record_json)).lease).not.toHaveProperty('processlessAt') + }) +}) + +describe('a record the file set aside', () => { + it('copies a record this build cannot read verbatim, and never grants it', async () => { + const unsupported = { ...record(ALPHA), schemaVersion: 1 } + await writeLegacy(legacyFile([unsupported])) + + const { database, store } = await install() + + expect(store.getRecord(ALPHA)).toBeNull() + expect(store.isSessionUnreadable(ALPHA)).toBe(true) + await expect(store.setConversationName(ALPHA, 'x')).rejects.toThrow( + 'execution_owner_reconciling' + ) + const stored = database.db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(ALPHA) + expect(JSON.parse(String(stored?.record_json))).toEqual(unsupported) + }) + + // #23589: both copies exist only when an older build recovered the readable one from its backup, + // so the set-aside copy may already have granted fences the readable one cannot show. + it('keeps the readable copy and raises its floor above both: 8, never 3, never 7 twice', async () => { + const readable = record(ALPHA, { runtimeFence: 2 }) + const setAside = { ...record(ALPHA, { runtimeFence: 7 }), schemaVersion: 99 } + await writeLegacy( + legacyFile([readable], { + unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: setAside } } + }) + ) + + const { store } = await install() + + const lease = store.getRecord(ALPHA)?.lease + expect(lease?.runtimeFence).toBe(2) + expect(lease?.minimumNextFence).toBe(8) + expect(nextAgentSessionFence(lease!)).toBe(8) + }) + + it('takes a record the primary set aside from the backup, with its floor raised', async () => { + const readable = record(ALPHA, { runtimeFence: 3 }) + await writeLegacy( + legacyFile([], { + unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: { schemaVersion: 99 } } } + }), + legacyFile([readable]) + ) + + const { store } = await install() + + // The commit the backup lost may have granted 4, so the next grant clears it. + expect(store.getRecord(ALPHA)?.lease.minimumNextFence).toBe(5) + }) +}) + +describe('a primary the backup stands in for', () => { + it('copies the backup with every floor raised past what the lost commit could have granted', async () => { + await writeLegacy('{ truncated', legacyFile([record(ALPHA, { runtimeFence: 1 })])) + + const { store, reports } = await install() + + expect(reports).toEqual([]) + expect(store.getRecord(ALPHA)?.lease).toMatchObject({ runtimeFence: 1, minimumNextFence: 3 }) + expect(await readFile(legacyPath(), 'utf-8')).toBe('{ truncated') + }) + + it('reports both copies unusable and copies nothing', async () => { + await writeLegacy('{ truncated', '{ also truncated') + + const { store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unusable' }]) + expect(store.listRecords()).toEqual([]) + }) + + // A read that can clear says nothing about the backup's contents, so the copy stays owed. + it('keeps the copy owed when the backup cannot be read behind a torn primary', async () => { + await writeLegacy('{ truncated') + await mkdir(`${legacyPath()}.bak`) + + const { database, store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unavailable' }]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(3) + expect(store.listRecords()).toEqual([]) + }) + + // Falling back would replace the primary's newer state with the backup's older one for good. + it('never takes a valid backup when the primary cannot be read', async () => { + await mkdir(legacyPath(), { recursive: true }) + await writeFile(`${legacyPath()}.bak`, JSON.stringify(legacyFile([record(ALPHA)]))) + + const { database, store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unavailable' }]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(3) + expect(store.listRecords()).toEqual([]) + }) +}) + +describe('the tab index from before the table', () => { + it('seeds the table from the visible list, keeping each chat on the id it has today', async () => { + const gamma = 'session-gamma' + await writeLegacy( + legacyFile( + [ + { ...record(ALPHA), surfaceTabId: 'tab-alpha' }, + { ...record(BETA), surfaceTabId: `structured-agent-session-${BETA}` }, + record(gamma) + ], + { visibleSessionIds: [ALPHA, BETA] } + ) + ) + + const { store } = await install() + + expect(store.getSessionTabId(ALPHA)).toBe('tab-alpha') + expect(store.getSessionTabId(BETA)).toBe(`structured-agent-session-${BETA}`) + expect(store.getSessionTabId(gamma)).toBeNull() + expect(store.listVisibleSessionIds()).toEqual([ALPHA, BETA]) + }) + + it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { + const cleared = (replacementSessionId: string) => ({ + command: 'clear', + state: 'completed', + phase: 'committed', + operationId: OPERATION_ID, + callerKey: 'client-1', + replacementSessionId + }) + await writeLegacy( + legacyFile( + [ + { ...record(ALPHA), conversationCommand: cleared('clear-one') }, + { ...record('clear-one'), conversationCommand: cleared('clear-two') }, + { ...record('clear-two'), surfaceTabId: 'structured-agent-session-clear-two' } + ], + { visibleSessionIds: [ALPHA, 'clear-two'] } + ) + ) + + const { store } = await install() + + expect(store.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) + const reopenedTab = store.getSessionTabId(ALPHA) + expect(reopenedTab).not.toBe(LEGACY_TAB_ID) + expect(reopenedTab).not.toContain(':') + expect(store.listVisibleSessionIds()).toEqual([ALPHA, 'clear-two']) + }) + + it('leaves the index unrecorded when only a chat created while the copy was owed recorded one', async () => { + await mkdir(legacyPath(), { recursive: true }) + const owed = await install() + owed.database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run(BETA, JSON.stringify(record(BETA))) + await AgentSessionRecordStore.open({ + journalDatabase: owed.database, + hostId: 'local' + }).setSessionTabVisibility(BETA, true) + owed.database.close() + await rm(legacyPath(), { recursive: true }) + await writeLegacy(legacyFile([record(ALPHA)])) + + const { database, store } = await install() + + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.listRecords().map(({ sessionId }) => sessionId)).toEqual([BETA, ALPHA]) + // Restore then takes the profile's tabs, beside the tab the owed-era chat left. + expect(store.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [BETA] }) + }) + + it('reads a recorded table, never the record field', async () => { + await writeLegacy( + legacyFile([{ ...record(ALPHA), surfaceTabId: 'tab-stale' }], { + sessionTabs: [{ tabId: 'tab-alpha', sessionId: ALPHA }] + }) + ) + + expect((await install()).store.getSessionTabId(ALPHA)).toBe('tab-alpha') + }) + + it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { + await writeLegacy( + legacyFile([{ ...record(ALPHA), surfaceTabId: `agent-session:${ALPHA}` }], { + visibleSessionIds: [ALPHA] + }) + ) + + const { store } = await install() + + expect(store.isSessionUnreadable(ALPHA)).toBe(false) + expect(store.getSessionTabId(ALPHA)).toBe(LEGACY_TAB_ID) + }) + + it('treats a malformed table as an unusable file rather than guessing', async () => { + await writeLegacy( + legacyFile([record(ALPHA)], { + sessionTabs: [ + { tabId: 'tab-alpha', sessionId: ALPHA }, + { tabId: 'tab-alpha', sessionId: BETA } + ] + }) + ) + + const { store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unusable' }]) + expect(store.listRecords()).toEqual([]) + expect(store.getVisibleSessionTabIndex().present).toBe(false) + }) +}) diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts new file mode 100644 index 000000000000..0a0b4d49d5bb --- /dev/null +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -0,0 +1,161 @@ +// The one-time copy of `agent-sessions.json` into the chat journal database, inside the version-4 +// migration (journal-database.ts). The file and its `.bak` are only ever read: an older build that +// still uses them finds them as it left them. +// +// Never blocks the host. A file no read will make usable is reported, nothing is copied, and the +// migration completes: the file is left untouched, but a later repair of it is never imported. A +// read that can clear leaves the copy owed, and every later launch retries it until one reads the +// file, finds it absent or unusable, or the import is retired. + +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + NO_LEGACY_JOURNAL_RECORDS, + type JournalLegacyRecordImport +} from '../native-chat/agent-session-journal/journal-database' +import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' +import { + legacyAgentSessionStorePath, + loadAgentSessionStore, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import { + insertAgentSessionStoreRowsIfAbsent, + withoutRetiredLeaseLatches, + type AgentSessionStoreImportRows +} from './agent-session-record-rows' +import { isReadableAgentSessionStoreRecord } from './agent-session-store-row-rules' + +export type LegacyAgentSessionRecords = + | { kind: 'absent' } + /** The primary parsed, or it was unusable and `.bak` parsed (`fromBackup`). */ + | { kind: 'loaded'; state: AgentSessionStoreState; fromBackup: boolean } + /** Present, and neither the primary nor `.bak` parses: no read will change that. */ + | { kind: 'unusable'; error: unknown } + /** Reading the primary or `.bak` failed in a way that can clear (EACCES, EIO, EMFILE). */ + | { kind: 'unavailable'; error: unknown } + +/** Why the copy found nothing to take, or took less than the file holds. */ +export type LegacyAgentSessionRecordImportReport = + | { kind: 'unusable' | 'unavailable'; error: unknown } + | { kind: 'host-mismatch'; fileHostId: string; hostId: string } + +function readFailedTransiently(error: unknown): boolean { + const cause = error instanceof Error ? error.cause : undefined + return ( + !(error instanceof Error && error.message === 'agent_session_store_corrupt') || + (typeof cause === 'object' && cause !== null && 'code' in cause && cause.code !== 'ENOENT') + ) +} + +/** Never throws. */ +export async function readLegacyAgentSessionRecords( + stateDirectory: string, + hostId: string +): Promise { + try { + const loaded = await loadAgentSessionStore(legacyAgentSessionStorePath(stateDirectory), hostId) + return loaded.storeFound + ? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup } + : { kind: 'absent' } + } catch (error) { + return readFailedTransiently(error) + ? { kind: 'unavailable', error } + : { kind: 'unusable', error } + } +} + +function safeFence(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null +} + +/** + * A readable record whose id also has a set-aside copy: an older build recovered the readable one + * from its backup and could not read the newer copy, which may already have granted fences the + * readable one cannot show. The next grant must clear both. + */ +function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): AgentSessionRecord { + let floor = Math.max(record.lease.minimumNextFence ?? 0, nextAgentSessionFence(record.lease) + 1) + const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined + if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) { + const runtimeFence = safeFence(rawLease.runtimeFence) + const minimumNextFence = + 'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined + ? safeFence(rawLease.minimumNextFence) + : undefined + if (runtimeFence !== null && minimumNextFence !== null) { + floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence })) + } + } + if (!Number.isSafeInteger(floor)) { + throw new Error('agent_session_fence_exhausted') + } + return { ...record, lease: { ...record.lease, minimumNextFence: floor } } +} + +function importRows( + state: AgentSessionStoreState, + fromBackup: boolean +): AgentSessionStoreImportRows { + const records = new Map(state.records) + if (fromBackup) { + // The commit the backup lost may have granted a fence it cannot show: main's rule for this state. + raiseAgentSessionFencesAfterBackupRecovery({ ...state, records }) + } + const rows: AgentSessionStoreImportRows['records'] = [] + for (const [sessionId, loaded] of records) { + const setAside = state.unreadableRecords.get(sessionId) + const record = setAside ? withFloorAboveSetAsideCopy(loaded, setAside.raw) : loaded + const json = JSON.stringify({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) + // A record the load rules refuse is kept as its bytes, which every load then sets aside. + rows.push([ + sessionId, + isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) ? json : JSON.stringify(loaded) + ]) + } + for (const [sessionId, { raw }] of state.unreadableRecords) { + if (!records.has(sessionId)) { + // Verbatim: a build that can read it gets it back, and this one derives it as unreadable. + rows.push([sessionId, JSON.stringify(raw ?? null)]) + } + } + return { + records: rows, + operations: [...state.operations].map(([key, row]) => [key, JSON.stringify(row)]), + retiredClaimKeys: state.retiredClaimKeys, + sessionTabs: + state.sessionTabs?.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })) ?? null + } +} + +/** What the migration runs for `legacy`, each failure reported once here. */ +export function legacyAgentSessionRecordImport( + legacy: LegacyAgentSessionRecords, + hostId: string, + report: (report: LegacyAgentSessionRecordImportReport) => void +): JournalLegacyRecordImport { + switch (legacy.kind) { + case 'absent': + return NO_LEGACY_JOURNAL_RECORDS + case 'unavailable': + report(legacy) + return { owed: true } + case 'unusable': + report(legacy) + return NO_LEGACY_JOURNAL_RECORDS + case 'loaded': + break + } + let rows: AgentSessionStoreImportRows + try { + rows = importRows(legacy.state, legacy.fromBackup) + } catch (error) { + report({ kind: 'unusable', error }) + return NO_LEGACY_JOURNAL_RECORDS + } + if (legacy.state.hostId !== hostId) { + // Owners it names answer `unresolved` against this host, the conservative verdict. + report({ kind: 'host-mismatch', fileHostId: legacy.state.hostId, hostId }) + } + return { owed: false, write: (db) => insertAgentSessionStoreRowsIfAbsent(db, rows) } +} diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts new file mode 100644 index 000000000000..be6f70f06e7d --- /dev/null +++ b/src/main/runtime/agent-session-record-rows.ts @@ -0,0 +1,276 @@ +// The agent-session store's rows in the host's chat journal database: every row loaded once at open, +// exactly the rows a transaction changed written back, and the one-time copy of the records file. +// +// A record row this build cannot read is derived as unreadable at each load and never rewritten: a +// write only touches changed rows, and every mutation of an unreadable id is refused. + +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + isPersistedAgentSessionRecord, + type AgentSessionLease +} from '../../shared/agent-session-record' +import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' +import type Database from '../sqlite/sync-database' +import type { SqliteRow } from '../sqlite/sqlite-statement' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import type { AgentSessionStoreRowWrites } from './agent-session-store-draft' +import { + isReadableAgentSessionStoreOperation, + isReadableAgentSessionStoreRecord, + isReadableAgentSessionStoreTab, + isReadableRetiredAgentSessionClaimKey +} from './agent-session-store-row-rules' +import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table' + +/** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first; + * no row count can say which. */ +const SESSION_TABS_RECORDED = 'session_tabs_recorded' + +/** Latch fields older builds wrote. Nothing reads them, and dropping them keeps a lease this build + * writes back from carrying a stale latch to an older build after a downgrade. */ +type RetiredAgentSessionLeaseFields = { + processlessAt?: unknown + settlementRetryRequired?: unknown + settlementRetryId?: unknown +} + +export function withoutRetiredLeaseLatches(lease: AgentSessionLease): AgentSessionLease { + const { + processlessAt: _processlessAt, + settlementRetryRequired: _settlementRetryRequired, + settlementRetryId: _settlementRetryId, + ...current + }: AgentSessionLease & RetiredAgentSessionLeaseFields = lease + return current +} + +function text(row: SqliteRow, column: string): string | null { + const value = row[column] + return typeof value === 'string' ? value : null +} + +function parseJson(json: string | null): { ok: true; value: unknown } | { ok: false } { + if (json === null) { + return { ok: false } + } + try { + return { ok: true, value: JSON.parse(json) } + } catch { + return { ok: false } + } +} + +function unreadableRecordReason(value: unknown): string { + if (isPersistedAgentSessionRecord(value)) { + return 'record_key_session_id_mismatch' + } + const schemaVersion = + typeof value === 'object' && value !== null && 'schemaVersion' in value + ? value.schemaVersion + : undefined + return schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION + ? 'current_shape_invalid' + : 'unsupported_schema' +} + +/** + * The whole store, read once. Every lease loads unreconciled: the process that wrote it may still + * be alive, so nothing persisted grants a writer until this host adjudicates it. Operation, key and + * tab rows this build cannot read are skipped, as a record row it cannot read is set aside. + */ +export function loadAgentSessionStoreRows( + db: Database.Database, + hostId: string +): AgentSessionStoreState { + const state: AgentSessionStoreState = { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId, + records: new Map(), + operations: new Map(), + retiredClaimKeys: [], + unreadableRecords: new Map(), + sessionTabs: null + } + // Only a read-only database a newer build wrote can lack them; its chats then list as none here. + if ( + !db + .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('agent_session_records') + ) { + return state + } + for (const row of db + .prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY rowid') + .all()) { + const sessionId = text(row, 'session_id') + if (sessionId === null) { + continue + } + const parsed = parseJson(text(row, 'record_json')) + const value = parsed.ok ? parsed.value : text(row, 'record_json') + if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) { + const { record } = normalizeLegacyHandoffRecord(value) + state.records.set(sessionId, { + ...record, + lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } + }) + } else { + state.unreadableRecords.set(sessionId, { reason: unreadableRecordReason(value), raw: value }) + } + } + for (const row of db + .prepare('SELECT operation_key, row_json FROM agent_session_operations ORDER BY rowid') + .all()) { + const key = text(row, 'operation_key') + const parsed = parseJson(text(row, 'row_json')) + if (key !== null && parsed.ok && isReadableAgentSessionStoreOperation(key, parsed.value)) { + state.operations.set(key, parsed.value) + } + } + for (const row of db + .prepare( + 'SELECT key_id AS keyId, retired_at AS retiredAt FROM agent_session_retired_claim_keys ORDER BY rowid' + ) + .all()) { + const entry = { keyId: row.keyId, retiredAt: Number(row.retiredAt) } + if (isReadableRetiredAgentSessionClaimKey(entry)) { + state.retiredClaimKeys.push(entry) + } + } + const recorded = db + .prepare('SELECT 1 AS present FROM agent_session_store_meta WHERE key = ?') + .get(SESSION_TABS_RECORDED) + const table = new AgentSessionTabTable() + for (const row of db + .prepare( + 'SELECT tab_id AS tabId, session_id AS sessionId FROM agent_session_tabs ORDER BY position' + ) + .all()) { + const entry = { tabId: row.tabId, sessionId: row.sessionId } + if ( + isReadableAgentSessionStoreTab(entry) && + table.sessionIdFor(entry.tabId) === undefined && + table.tabIdFor(entry.sessionId) === undefined + ) { + table.show(entry.sessionId, entry.tabId) + } + } + if (recorded) { + state.sessionTabs = table + } else if (table.sessionIds().length > 0) { + state.unrecordedSessionTabs = table + } + return state +} + +function writeTabIndex( + db: Database.Database, + index: { recorded: boolean; tabs: readonly PersistedAgentSessionTab[] } +): void { + db.prepare('DELETE FROM agent_session_tabs').run() + const insert = db.prepare( + 'INSERT INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)' + ) + index.tabs.forEach(({ tabId, sessionId }, position) => insert.run(tabId, sessionId, position)) + if (index.recorded) { + db.prepare('INSERT OR REPLACE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( + SESSION_TABS_RECORDED, + '1' + ) + } else { + db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) + } +} + +/** Exactly the rows a transaction changed. The caller holds the journal transaction. */ +export function writeAgentSessionStoreRows( + db: Database.Database, + writes: AgentSessionStoreRowWrites +): void { + const upsertRecord = db.prepare( + 'INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET record_json = excluded.record_json' + ) + for (const [sessionId, json] of writes.records.upsert) { + upsertRecord.run(sessionId, json) + } + for (const sessionId of writes.records.remove) { + db.prepare('DELETE FROM agent_session_records WHERE session_id = ?').run(sessionId) + } + const upsertOperation = db.prepare( + 'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?) ON CONFLICT(operation_key) DO UPDATE SET row_json = excluded.row_json' + ) + for (const [key, json] of writes.operations.upsert) { + upsertOperation.run(key, json) + } + for (const key of writes.operations.remove) { + db.prepare('DELETE FROM agent_session_operations WHERE operation_key = ?').run(key) + } + if (writes.retiredClaimKeys) { + db.prepare('DELETE FROM agent_session_retired_claim_keys').run() + const insert = db.prepare( + 'INSERT INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ) + for (const { keyId, retiredAt } of writes.retiredClaimKeys) { + insert.run(keyId, retiredAt) + } + } + if (writes.sessionTabs) { + writeTabIndex(db, writes.sessionTabs) + } +} + +/** What the one-time import copies in, each row already serialized. */ +export type AgentSessionStoreImportRows = { + records: [sessionId: string, json: string][] + operations: [key: string, json: string][] + retiredClaimKeys: { keyId: string; retiredAt: number }[] + /** Null when the file never recorded a tab index. */ + sessionTabs: PersistedAgentSessionTab[] | null +} + +/** Inserts each row whose key the database does not hold yet: a chat created while the import was + * owed keeps its own rows, and ids never collide with the file's. */ +export function insertAgentSessionStoreRowsIfAbsent( + db: Database.Database, + rows: AgentSessionStoreImportRows +): void { + const insertRecord = db.prepare( + 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ) + for (const [sessionId, json] of rows.records) { + insertRecord.run(sessionId, json) + } + const insertOperation = db.prepare( + 'INSERT OR IGNORE INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)' + ) + for (const [key, json] of rows.operations) { + insertOperation.run(key, json) + } + const insertKey = db.prepare( + 'INSERT OR IGNORE INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ) + for (const { keyId, retiredAt } of rows.retiredClaimKeys) { + insertKey.run(keyId, retiredAt) + } + const tabs = rows.sessionTabs + if (tabs) { + const insertTab = db.prepare( + 'INSERT OR IGNORE INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)' + ) + // Ahead of any tab a chat created while the import was owed recorded: these were open first. + tabs.forEach(({ tabId, sessionId }, position) => + insertTab.run(tabId, sessionId, position - tabs.length) + ) + db.prepare('INSERT OR IGNORE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( + SESSION_TABS_RECORDED, + '1' + ) + } else { + // An index a chat created while the import was owed recorded holds none of the file's chats: + // "never recorded" sends restore to the profile's tabs, with that chat's tab row beside them. + db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) + } +} diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 0aa32f74f755..79e65388fbd0 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -1,13 +1,9 @@ /** - * On-disk layer for the durable agent-session store. - * - * Every mutation is a whole-file atomic transaction — temp write, fsync, rename — so a SIGKILL - * at any point leaves either the previous committed state or the next one, never a torn lease. - * That matters because this host restarts its runtime often; a half-written lease would be - * indistinguishable from an owner whose identity cannot be verified. + * The records file the agent-session store kept before it moved into the chat journal database, + * read once by the version-4 migration (agent-session-legacy-record-import.ts) and never written. + * Its `.bak` fallback and salvage survive here for that one read, until the import is retired. */ -import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { @@ -21,15 +17,11 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' -import { agentSessionStoreBackupPath as backupPath } from './agent-session-record-store-write' -export { saveAgentSessionStore } from './agent-session-record-store-write' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' -import { serializeAgentSessionStoreState } from './agent-session-store-serialization' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const -/** In a host's state directory, beside the journal database: one file adjudicates every session's - * lease. */ +/** In a host's state directory, beside the journal database, where the records file lived. */ export const AGENT_SESSION_STORE_DIR_NAME = 'agent-sessions' export const AGENT_SESSION_STORE_FILE_NAME = 'agent-sessions.json' @@ -46,6 +38,8 @@ export type AgentSessionStoreState = { unreadableRecords: Map /** Chat tab id → the conversation it shows; null until this store first records a tab. */ sessionTabs: AgentSessionTabTable | null + /** Tab rows an index never recorded holds (chats opened while the import was owed). */ + unrecordedSessionTabs?: AgentSessionTabTable } export type LoadedAgentSessionStore = { @@ -55,16 +49,19 @@ export type LoadedAgentSessionStore = { readOnly: boolean /** True when the primary file was unusable and the previous committed copy was used. */ recoveredFromBackup: boolean - /** True when the normalized current-schema quarantine must be persisted. */ - needsRewrite: boolean - /** True when decode mapped a lease value only the removed terminal handoff wrote. */ - legacyHandoffLeasesNormalized: boolean } export function agentSessionStorePath(directory: string): string { return join(directory, AGENT_SESSION_STORE_FILE_NAME) } +/** The records file of the host whose state directory this is. */ +export function legacyAgentSessionStorePath(stateDirectory: string): string { + return agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME)) +} + +const backupPath = (filePath: string): string => `${filePath}.bak` + function emptyState(hostId: string): AgentSessionStoreState { return { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -77,21 +74,7 @@ function emptyState(hostId: string): AgentSessionStoreState { } } -export function agentSessionStoreRevision(state: AgentSessionStoreState): string { - return createHash('sha256') - .update(String(state.schemaVersion)) - .update('\0') - .update(serializeAgentSessionStoreState(state)) - .digest('hex') -} - -function parseState( - raw: string, - hostId: string -): Pick< - LoadedAgentSessionStore, - 'state' | 'needsRewrite' | 'legacyHandoffLeasesNormalized' -> | null { +function parseState(raw: string, hostId: string): Pick | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -144,8 +127,6 @@ function parseState( const state = emptyState(hostId) state.schemaVersion = schemaVersion state.hostId = file.hostId - let needsRewrite = false - let legacyHandoffLeasesNormalized = false if (typeof file.records === 'object' && file.records !== null) { for (const [sessionId, value] of Object.entries(file.records)) { const decoded = isPersistedAgentSessionRecord(value) @@ -154,8 +135,6 @@ function parseState( const record = decoded?.record ?? null if (record?.sessionId === sessionId) { state.records.set(sessionId, record) - // Why: mapped while parsing, so every revision is taken over the same normalized state. - legacyHandoffLeasesNormalized ||= decoded?.normalized === true } else { const valueSchemaVersion = typeof value === 'object' && @@ -167,7 +146,6 @@ function parseState( ? 'current_shape_invalid' : 'unsupported_schema' state.unreadableRecords.set(sessionId, { reason, raw: value }) - needsRewrite ||= schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION } } } @@ -233,7 +211,7 @@ function parseState( return null } state.sessionTabs = sessionTabs.table - return { state, needsRewrite, legacyHandoffLeasesNormalized } + return { state } } /** A record the primary retained as unreadable may still have a valid copy in the previous @@ -283,12 +261,9 @@ export async function loadAgentSessionStore( } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { // Only a missing or unparseable primary means "fall back". A transient read failure - // (EACCES, EIO, EMFILE) says nothing about the primary's contents, and treating it as - // recovery would replace newer state with a stale backup and latch the recovery path. - if (!recoveredFromBackup) { - throw new Error('agent_session_store_corrupt') - } - unusableStoreFound = true + // (EACCES, EIO, EMFILE) says nothing about either copy's contents: the primary is not + // replaced by a stale backup, and a backup that could not be read is not unusable. + throw new Error('agent_session_store_corrupt', { cause: error }) } continue } @@ -314,8 +289,6 @@ export async function loadAgentSessionStore( state: emptyState(hostId), storeFound: false, readOnly: false, - recoveredFromBackup: false, - needsRewrite: false, - legacyHandoffLeasesNormalized: false + recoveredFromBackup: false } } diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts deleted file mode 100644 index 4180af2580b3..000000000000 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Decoding a lease the removed terminal handoff wrote. Separate from the store's main suite only - * because that file is at its line cap. - */ - -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import { - readPersistedLease, - writeOlderBuildLease -} from './agent-session-older-build-lease.test-fixture' -import type { AgentSessionRecordStore } from './agent-session-record-store' -import { - openTestAgentSessionRecordStore, - testAgentSessionStoreFilePath -} from './agent-session-record-store-test-harness' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } - -let directory: string - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-legacy-lease-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -const open = (): Promise => openTestAgentSessionRecordStore(directory) - -async function persistLiveOwner(): Promise { - const store = await open() - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - // Without one the next open backfills it, and that rewrite would mask the lease's own. - surfaceTabId: 'tab-alpha', - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'no answer' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-${'0'.repeat(32)}`, - fingerprint: 'fp' - }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken: 'spawn-a' }, - now: NOW - }) - await store.proveOwner({ - sessionId: SESSION, - fence, - link: { - linkId: 'link-1', - origin: 'created', - mintedAtFence: fence, - observedAt: NOW, - handle: { provider: 'claude', sessionId: 'provider-session-alpha', leafUuid: null } - }, - now: NOW - }) - // Commits the visibility index, so a later hide changes nothing but a pending rewrite. - await store.setSessionTabVisibility(SESSION, false) -} - -describe('a lease the removed terminal handoff wrote', () => { - it('loads normalized instead of quarantined, and reaches disk with the first transaction', async () => { - await persistLiveOwner() - await writeOlderBuildLease(directory, SESSION, { - runtimeKind: 'tui', - handoffStage: 'old-owner-stopped', - handoffOperationId: 'op-handoff' - }) - const legacy = await readFile(testAgentSessionStoreFilePath(directory), 'utf-8') - - const store = await open() - expect(store.isSessionUnreadable(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering', - handoffOperationId: 'op-handoff', - claimStatus: 'conflicted' - }) - // An open does not write: another holder of the file mid-restart would read it as a change. - expect(await readFile(testAgentSessionStoreFilePath(directory), 'utf-8')).toBe(legacy) - - // A transaction that changes nothing still writes it. Had the open's revision been taken over - // anything but the normalized state, this would read the file as externally changed and - // reload it, dropping the pending write. - await store.setSessionTabVisibility(SESSION, false) - expect(await readPersistedLease(directory, SESSION)).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering', - claimStatus: 'conflicted' - }) - }) - - it('keeps the adjudicated lease after a save, so the saved file hashes to memory', async () => { - await persistLiveOwner() - await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) - const store = await open() - await store.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 1_000 }) - expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) - - // A reload after a mismatched hash would mark every lease unadjudicated again. - await store.setSessionTabVisibility(SESSION, true) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'conflicted', - handoffStage: 'recovering' - }) - const settled = await readFile(testAgentSessionStoreFilePath(directory), 'utf-8') - await open() - expect(await readFile(testAgentSessionStoreFilePath(directory), 'utf-8')).toBe(settled) - }) -}) diff --git a/src/main/runtime/agent-session-record-store-security.test.ts b/src/main/runtime/agent-session-record-store-security.test.ts deleted file mode 100644 index 63ee368f8737..000000000000 --- a/src/main/runtime/agent-session-record-store-security.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { chmod, mkdtemp, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -let directory: string - -function reserveRequest(): AgentSessionReserveRequest { - return { - sessionId: 'session-created', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-created', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/created' }, - expectedFence: null, - spawnToken: 'spawn-created', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-security-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('agent session record store security', () => { - it.skipIf(process.platform === 'win32')( - 'creates the directory and store owner-only', - async () => { - const nestedDirectory = join(directory, 'agent-sessions') - await chmod(directory, 0o755) - const store = await AgentSessionRecordStore.open({ - directory: nestedDirectory, - hostId: 'local' - }) - await store.reserveOwner(reserveRequest()) - - expect((await stat(nestedDirectory)).mode & 0o777).toBe(0o700) - expect((await stat(agentSessionStorePath(nestedDirectory))).mode & 0o777).toBe(0o600) - } - ) -}) diff --git a/src/main/runtime/agent-session-record-store-security.ts b/src/main/runtime/agent-session-record-store-security.ts deleted file mode 100644 index b7293f56c99e..000000000000 --- a/src/main/runtime/agent-session-record-store-security.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { chmod, mkdir } from 'node:fs/promises' -import { dirname } from 'node:path' -import { - loadAgentSessionStore, - type LoadedAgentSessionStore -} from './agent-session-record-store-file' -import { withFileTransactionLock } from '../file-transaction-lock' - -const OWNER_DIRECTORY_MODE = 0o700 -const OWNER_FILE_MODE = 0o600 - -async function chmodIfPresent(path: string, mode: number): Promise { - try { - await chmod(path, mode) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } -} - -export async function hardenAgentSessionStorePermissions(filePath: string): Promise { - const directory = dirname(filePath) - await mkdir(directory, { recursive: true, mode: OWNER_DIRECTORY_MODE }) - await chmod(directory, OWNER_DIRECTORY_MODE) - await Promise.all([ - chmodIfPresent(filePath, OWNER_FILE_MODE), - chmodIfPresent(`${filePath}.bak`, OWNER_FILE_MODE) - ]) -} - -export async function loadProtectedAgentSessionStore( - filePath: string, - hostId: string -): Promise { - return withFileTransactionLock(filePath, async () => { - await hardenAgentSessionStorePermissions(filePath) - return loadAgentSessionStore(filePath, hostId) - }) -} diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index c5d4be5319cf..40ee8a36b86c 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -3,7 +3,7 @@ * Separate from the store's main suite only because that file is at its line cap. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -13,8 +13,7 @@ import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refu import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore, - readPersistedTestAgentSessionStore, - testAgentSessionStoreFilePath + readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' @@ -70,8 +69,6 @@ async function open(): Promise { describe('chat tab table', () => { const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' - const filePath = () => testAgentSessionStoreFilePath(directory) - const readFileJson = async () => JSON.parse(await readFile(filePath(), 'utf-8')) it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { const store = await open() @@ -159,123 +156,4 @@ describe('chat tab table', () => { await store.setSessionTabVisibility('session-alpha', true, 'tab-restored') expect(store.getSessionTabId('session-alpha')).toBe('tab-restored') }) - - it('seeds the table from an older store, keeping each visible chat on the id it has today', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - await first.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) - await first.reserveOwner( - reserveRequest({ - sessionId: 'session-gamma', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-3' } - }) - ) - // What a build before the table wrote: the visible list, and a tab id on some records. - const raw = await readFileJson() - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha', 'session-beta'] - raw.records['session-alpha'].surfaceTabId = 'tab-alpha' - raw.records['session-beta'].surfaceTabId = 'structured-agent-session-session-beta' - const legacy = JSON.stringify(raw) - await writeFile(filePath(), legacy) - - const reopened = await open() - expect(reopened.getSessionTabId('session-alpha')).toBe('tab-alpha') - expect(reopened.getSessionTabId('session-beta')).toBe('structured-agent-session-session-beta') - expect(reopened.getSessionTabId('session-gamma')).toBeNull() - // Seeding is part of reading, so an open alone writes nothing. - expect(await readFile(filePath(), 'utf-8')).toBe(legacy) - - await reopened.setSessionTabVisibility('session-gamma', true) - const persisted = await readFileJson() - expect(persisted.sessionTabs).toEqual([ - { tabId: 'tab-alpha', sessionId: 'session-alpha' }, - { tabId: 'structured-agent-session-session-beta', sessionId: 'session-beta' }, - { tabId: 'structured-agent-session-session-gamma', sessionId: 'session-gamma' } - ]) - // Older builds restore from this list; the record field rides along untouched and unread. - expect(persisted.visibleSessionIds).toEqual(['session-alpha', 'session-beta', 'session-gamma']) - expect(persisted.records['session-alpha'].surfaceTabId).toBe('tab-alpha') - }) - - it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { - const first = await open() - for (const [index, sessionId] of ['session-alpha', 'clear-one', 'clear-two'].entries()) { - await first.reserveOwner( - reserveRequest({ - sessionId, - operation: { - callerKey: 'client-1', - operationId: operationId(), - fingerprint: `fp-chain-${index}` - } - }) - ) - } - // An older build after two clears, with the first conversation reopened from history. - const raw = await readFileJson() - const cleared = (replacementSessionId: string) => ({ - command: 'clear', - state: 'completed', - phase: 'committed', - operationId: operationId(), - callerKey: 'client-1', - replacementSessionId - }) - raw.records['session-alpha'].conversationCommand = cleared('clear-one') - raw.records['clear-one'].conversationCommand = cleared('clear-two') - raw.records['clear-two'].surfaceTabId = 'structured-agent-session-clear-two' - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha', 'clear-two'] - await writeFile(filePath(), JSON.stringify(raw)) - - const reopened = await open() - expect(reopened.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) - const reopenedTab = reopened.getSessionTabId('session-alpha') - expect(reopenedTab).not.toBe(LEGACY_TAB_ID) - expect(reopenedTab).not.toContain(':') - expect(reopened.listVisibleSessionIds()).toEqual(['session-alpha', 'clear-two']) - // Seeding is part of reading, so it must give the same ids on every read of the same bytes. - expect((await open()).getSessionTabId('session-alpha')).toBe(reopenedTab) - }) - - it('reads the table, never the record field, once the table is on disk', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - await first.setSessionTabVisibility('session-alpha', true, 'tab-alpha') - const raw = await readFileJson() - raw.records['session-alpha'].surfaceTabId = 'tab-stale' - await writeFile(filePath(), JSON.stringify(raw)) - expect((await open()).getSessionTabId('session-alpha')).toBe('tab-alpha') - }) - - it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const raw = await readFileJson() - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha'] - raw.records['session-alpha'].surfaceTabId = 'agent-session:session-alpha' - await writeFile(filePath(), JSON.stringify(raw)) - const reopened = await open() - expect(reopened.isSessionUnreadable('session-alpha')).toBe(false) - expect(reopened.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) - }) - - it('treats a malformed table as a corrupt store rather than guessing', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const raw = await readFileJson() - raw.sessionTabs = [ - { tabId: 'tab-alpha', sessionId: 'session-alpha' }, - { tabId: 'tab-alpha', sessionId: 'session-beta' } - ] - await writeFile(filePath(), JSON.stringify(raw)) - await expect(open()).rejects.toThrow('agent_session_store_corrupt') - }) }) diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index 74af065cd01b..eae3ae3baf4c 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -1,94 +1,189 @@ /** * The one way tests open, seed and read back the durable agent-session record store, so a change * to where or how the store persists is made here rather than in every test that uses it. Every - * function takes the host's state directory, the one its journal database lives in, and keeps the - * store where the runtime does within it. + * function takes the host's state directory: the store is rows in its chat journal database, opened + * through the test database registry, so `closeTestJournalHostDatabases` closes it too. */ -import { mkdir, readFile, writeFile } from 'node:fs/promises' -import { join } from 'node:path' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import type Database from '../sqlite/sync-database' import { AgentSessionRecordStore } from './agent-session-record-store' import { - AGENT_SESSION_STORE_DIR_NAME, AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath, type RetiredAgentSessionClaimKey } from './agent-session-record-store-file' const TEST_HOST_ID = 'local' -function storeDirectory(stateDirectory: string): string { - return join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME) -} - -/** The store's file, for the tests that assert on or damage its bytes directly. */ -export function testAgentSessionStoreFilePath(stateDirectory: string): string { - return agentSessionStorePath(storeDirectory(stateDirectory)) -} - /** One committed state of the store, as tests seed it and read it back. */ export type PersistedTestAgentSessionStore = { schemaVersion: number hostId: string + /** Every record row as stored, including one this build cannot read. */ records: Record operations: Record retiredClaimKeys: RetiredAgentSessionClaimKey[] + /** Written as raw record rows; always empty on a read, where `records` holds every row. */ unusableRecords: Record /** Absent until the store first records a chat tab. */ sessionTabs?: { tabId: string; sessionId: string }[] } +function databaseFor(stateDirectory: string): Database.Database { + return openTestJournalHostDatabase(stateDirectory).db +} + /** Opens, or reopens, the store in `stateDirectory`: what a fresh app process does at launch. */ -export function openTestAgentSessionRecordStore( +export async function openTestAgentSessionRecordStore( stateDirectory: string, options: { hostId?: string } = {} ): Promise { return AgentSessionRecordStore.open({ - directory: storeDirectory(stateDirectory), + journalDatabase: openTestJournalHostDatabase(stateDirectory), hostId: options.hostId ?? TEST_HOST_ID }) } +function writePersisted(db: Database.Database, persisted: PersistedTestAgentSessionStore): void { + db.exec('BEGIN IMMEDIATE') + try { + for (const table of [ + 'agent_session_records', + 'agent_session_operations', + 'agent_session_retired_claim_keys', + 'agent_session_tabs', + 'agent_session_store_meta' + ]) { + db.prepare(`DELETE FROM ${table}`).run() + } + const insertRecord = db.prepare( + 'INSERT OR REPLACE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ) + for (const [sessionId, { raw }] of Object.entries(persisted.unusableRecords)) { + insertRecord.run(sessionId, JSON.stringify(raw ?? null)) + } + for (const [sessionId, record] of Object.entries(persisted.records)) { + insertRecord.run(sessionId, JSON.stringify(record)) + } + for (const [key, row] of Object.entries(persisted.operations)) { + db.prepare( + 'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)' + ).run(key, JSON.stringify(row)) + } + for (const { keyId, retiredAt } of persisted.retiredClaimKeys) { + db.prepare( + 'INSERT INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ).run(keyId, retiredAt) + } + if (persisted.sessionTabs) { + persisted.sessionTabs.forEach(({ tabId, sessionId }, position) => + db + .prepare('INSERT INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)') + .run(tabId, sessionId, position) + ) + db.prepare( + "INSERT INTO agent_session_store_meta (key, value) VALUES ('session_tabs_recorded', '1')" + ).run() + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} + /** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ export async function seedTestAgentSessionRecordStore( stateDirectory: string, seed: { records: readonly PersistedAgentSessionRecord[] } ): Promise { - const persisted: PersistedTestAgentSessionStore = { + writePersisted(databaseFor(stateDirectory), { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: TEST_HOST_ID, records: Object.fromEntries(seed.records.map((record) => [record.sessionId, record])), operations: {}, retiredClaimKeys: [], unusableRecords: {} - } - await mkdir(storeDirectory(stateDirectory), { recursive: true }) - await writeFile(testAgentSessionStoreFilePath(stateDirectory), JSON.stringify(persisted), 'utf-8') + }) } /** Leaves an empty store a newer build wrote: this build reads it but never writes it. */ export async function seedTestAgentSessionStoreFromNewerBuild( stateDirectory: string ): Promise { - await mkdir(storeDirectory(stateDirectory), { recursive: true }) - await writeFile( - testAgentSessionStoreFilePath(stateDirectory), - JSON.stringify({ schemaVersion: 99, hostId: TEST_HOST_ID, records: {}, operations: {} }) + openTestJournalHostDatabase(stateDirectory).db.pragma( + `user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}` ) + closeTestJournalHostDatabase(stateDirectory) } -/** Everything the store has committed, as text: to assert a value never reached disk, or that an - * action wrote nothing by comparing two reads. */ -export function readPersistedTestAgentSessionStoreText(stateDirectory: string): Promise { - return readFile(testAgentSessionStoreFilePath(stateDirectory), 'utf-8') +function parsed(json: unknown): unknown { + return typeof json === 'string' ? JSON.parse(json) : null } export async function readPersistedTestAgentSessionStore( stateDirectory: string ): Promise { - return JSON.parse(await readPersistedTestAgentSessionStoreText(stateDirectory)) + const db = databaseFor(stateDirectory) + const persisted: PersistedTestAgentSessionStore = { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: TEST_HOST_ID, + records: {}, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + } + for (const row of db + .prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY rowid') + .all()) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test read-back of rows the store wrote; a test asserting on a malformed row reads it as the value it stored. + persisted.records[String(row.session_id)] = parsed( + row.record_json + ) as PersistedAgentSessionRecord + } + for (const row of db + .prepare('SELECT operation_key, row_json FROM agent_session_operations ORDER BY rowid') + .all()) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test read-back of rows the store wrote after validating them. + persisted.operations[String(row.operation_key)] = parsed( + row.row_json + ) as AgentSessionOperationRow + } + for (const row of db + .prepare('SELECT key_id, retired_at FROM agent_session_retired_claim_keys ORDER BY rowid') + .all()) { + persisted.retiredClaimKeys.push({ + keyId: String(row.key_id), + retiredAt: Number(row.retired_at) + }) + } + if ( + db + .prepare( + "SELECT 1 AS present FROM agent_session_store_meta WHERE key = 'session_tabs_recorded'" + ) + .get() + ) { + persisted.sessionTabs = db + .prepare('SELECT tab_id, session_id FROM agent_session_tabs ORDER BY position') + .all() + .map((row) => ({ tabId: String(row.tab_id), sessionId: String(row.session_id) })) + } + return persisted +} + +/** Everything the store has committed, as text: to assert a value never reached disk, or that an + * action wrote nothing by comparing two reads. */ +export async function readPersistedTestAgentSessionStoreText( + stateDirectory: string +): Promise { + return JSON.stringify(await readPersistedTestAgentSessionStore(stateDirectory)) } /** Changes the committed state behind the store's back, as an older build or a damaged disk would. */ @@ -98,5 +193,5 @@ export async function editPersistedTestAgentSessionStore( ): Promise { const persisted = await readPersistedTestAgentSessionStore(stateDirectory) edit(persisted) - await writeFile(testAgentSessionStoreFilePath(stateDirectory), JSON.stringify(persisted)) + writePersisted(databaseFor(stateDirectory), persisted) } diff --git a/src/main/runtime/agent-session-record-store-write.ts b/src/main/runtime/agent-session-record-store-write.ts deleted file mode 100644 index 76f68f03de33..000000000000 --- a/src/main/runtime/agent-session-record-store-write.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Committing the durable agent-session store. - * - * Split from the read/parse half so neither outgrows its budget. The ordering below is the whole - * reason this is its own file: the live path is never absent. New content is made durable in a temp - * file first, a validated primary is COPIED to the backup, and only then does the rename publish it. - * Backup recovery keeps the known-good backup in place while publishing the repaired primary. - * - * The old ordering renamed the live file aside before writing the new one, so a death in that - * window left the profile with a backup and no primary — which is exactly the state that wedged a - * real profile. Copy, don't move. - */ - -import { chmod, mkdir, rm } from 'node:fs/promises' -import { dirname } from 'node:path' -import { - copyFileDurable, - durableWriteTempPath, - renameDurable, - writeTempFileDurable -} from '../durable-file-write' -import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { serializeAgentSessionStoreState } from './agent-session-store-serialization' - -export const agentSessionStoreBackupPath = (filePath: string): string => `${filePath}.bak` - -export async function saveAgentSessionStore( - filePath: string, - state: AgentSessionStoreState, - options: { primaryStatus: 'validated' | 'unusable-or-absent' } -): Promise { - const directory = dirname(filePath) - await mkdir(directory, { recursive: true, mode: 0o700 }) - await chmod(directory, 0o700) - const tmpPath = durableWriteTempPath(filePath) - try { - await writeTempFileDurable(tmpPath, serializeAgentSessionStoreState(state), 0o600) - // Only a primary parsed under the transaction lock may replace the backup. During recovery the - // primary is corrupt or absent, so the known-good backup must survive until publication. - if (options.primaryStatus === 'validated') { - await copyFileDurable(filePath, agentSessionStoreBackupPath(filePath)) - } - await renameDurable(tmpPath, filePath) - } catch (error) { - await rm(tmpPath, { force: true }).catch(() => {}) - throw error - } -} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 908f843da981..12aa70b330cd 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -1,6 +1,6 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { @@ -16,10 +16,10 @@ import { editPersistedTestAgentSessionStore, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore, - seedTestAgentSessionStoreFromNewerBuild, - testAgentSessionStoreFilePath + seedTestAgentSessionStoreFromNewerBuild } from './agent-session-record-store-test-harness' import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -41,9 +41,6 @@ const FOLDER: AgentSessionExecutionLocation = { const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } const UNUSED: AgentSessionOwnerProbe = { outcome: 'reservation-unused' } -const BAD_OP_STORE = '{"schemaVersion":0,"hostId":"","records":{},"operations":{"x":0}}' -const BAD_KEY_STORE = - '{"schemaVersion":1,"hostId":"","records":{},"operations":{},"retiredClaimKeys":[0]}' let counter = 0 @@ -288,22 +285,6 @@ describe('acquisition path', () => { }) describe('concurrent claims', () => { - it('lets only one store instance reserve a session from the same disk snapshot', async () => { - const [first, second] = await Promise.all([open(), open()]) - const results = await Promise.allSettled([ - first.reserveOwner(reserveRequest()), - second.reserveOwner(reserveRequest()) - ]) - - expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) - const refused = results.find((result) => result.status === 'rejected') - expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_conflict') - - const persisted = await open() - expect(persisted.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) - expect(persisted.listOperationRows()).toHaveLength(1) - }) - it('lets exactly one of two concurrent reservations win and never spawns the loser', async () => { const store = await open() await establishOwner(store) @@ -378,18 +359,16 @@ describe('concurrent claims', () => { const store = await open() await establishOwner(store) const before = store.getRecord('session-alpha') - // Losing both committed copies must not reset the live store to empty authority. - await rm(directory, { recursive: true, force: true }) - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 9, sequence: 9 }, - now: NOW - }) - ).rejects.toThrow() - expect(store.getRecord('session-alpha')).toEqual(before) - expect(store.getRecord('session-alpha')?.lease.journalCheckpoint).toBeNull() + const persistedBefore = await readPersistedTestAgentSessionStore(directory) + // The row write fails inside the commit, as a full disk or an I/O error would. + openTestJournalHostDatabase(directory).db.exec( + "CREATE TRIGGER fail_record_write BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END" + ) + await expect(store.setConversationName('session-alpha', 'renamed')).rejects.toThrow( + 'disk I/O error' + ) + expect(store.getRecord('session-alpha')).toBe(before) + expect(await readPersistedTestAgentSessionStore(directory)).toEqual(persistedBefore) }) }) @@ -503,15 +482,14 @@ describe('restart reconciliation', () => { expect(record?.lease.provenHandleLinkId).toBe('link-1') }) - it('never applies a stale restart probe to a replacement owner', async () => { - const writer = await open() - await establishOwner(writer) - const reconciler = await open() + it('never applies a stale restart probe to a lease another reconcile already settled', async () => { + await establishOwner(await open()) + const store = await open() let releaseProbe!: (probe: AgentSessionOwnerProbe) => void let markProbeStarted!: () => void const probeStarted = new Promise((resolve) => (markProbeStarted = resolve)) const probeResult = new Promise((resolve) => (releaseProbe = resolve)) - const reconciliation = reconciler.reconcileOnRestart({ + const stale = store.reconcileOnRestart({ probe: async () => { markProbeStarted() return probeResult @@ -520,45 +498,16 @@ describe('restart reconciliation', () => { }) await probeStarted - await writer.evictProvenDeadOwner({ - sessionId: 'session-alpha', - expectedFence: 1, - probe: { outcome: 'pid-absent' }, - now: NOW + 100 - }) - const replacement = await writer.reserveOwner( - reserveRequest({ - expectedFence: 2, - probe: UNUSED, - spawnToken: 'spawn-b', - operation: { - callerKey: 'client-1', - operationId: operationId(NOW + 200), - fingerprint: 'fp-2' - }, - now: NOW + 200 - }) - ) - await writer.commitProcessIdentity({ - sessionId: 'session-alpha', - fence: replacement.record.lease.runtimeFence, - process: processIdentity({ pid: 5252, spawnToken: 'spawn-b' }), - now: NOW + 300 - }) - await writer.proveOwner({ - sessionId: 'session-alpha', - fence: replacement.record.lease.runtimeFence, - link: handleLink({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 3 }), - now: NOW + 300 - }) + const settled = await store.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 500 }) + expect(settled.get('session-alpha')?.lease).toMatchObject({ claimStatus: 'live' }) releaseProbe({ outcome: 'pid-absent' }) - expect(await reconciliation).toEqual(new Map()) + expect(await stale).toEqual(new Map()) const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.records['session-alpha'].lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 1, claimStatus: 'live', - ownerProcess: { pid: 5252, spawnToken: 'spawn-b' }, + ownerProcess: { pid: 4242, spawnToken: 'spawn-a' }, unreconciled: false }) }) @@ -757,7 +706,7 @@ describe('host and workspace isolation', () => { }) }) -describe('claim keys, checkpoints, and unreadable rows', () => { +describe('claim keys and unreadable rows', () => { it('keeps a retired claim key verifiable for the retention window', async () => { const store = await open() await store.retireClaimKey('key-1', NOW) @@ -770,40 +719,6 @@ describe('claim keys, checkpoints, and unreadable rows', () => { expect(store.isClaimKeyVerifiable('key-unknown', NOW)).toBe(true) }) - it('refuses a journal checkpoint that moves backwards', async () => { - const store = await open() - await establishOwner(store) - await store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 2, sequence: 10 }, - now: NOW - }) - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 2, sequence: 9 }, - now: NOW - }) - ).rejects.toThrow('agent_session_checkpoint_stale') - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 999 }, - now: NOW - }) - ).rejects.toThrow('agent_session_checkpoint_stale') - const advanced = await store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 3, sequence: 0 }, - now: NOW - }) - expect(advanced.lease.journalCheckpoint).toEqual({ epoch: 3, sequence: 0 }) - }) - it.each([ [ 'invalid checkpoint', @@ -824,55 +739,15 @@ describe('claim keys, checkpoints, and unreadable rows', () => { expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) }) - it('recovers the previous committed state when the primary file is corrupt', async () => { - const first = await open() - await establishOwner(first) - // A second commit leaves the first as the backup. - await first.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 1 }, - now: NOW - }) - await writeFile(testAgentSessionStoreFilePath(directory), '{ truncated') - - const reopened = await open() - expect(reopened.recoveredFromBackup).toBe(true) - expect(reopened.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) - - // The next transaction completes. It used to reject forever: the latch that guarded against - // the lost commit's fence had no exit, so a profile in this state could never write again. - await expect(reopened.retireClaimKey('key-2', NOW)).resolves.not.toThrow() - // Safety is kept by recording a FLOOR the next grant must clear, not by rewriting the current - // fence: `live` means a handle proven at exactly that number, so moving it would invalidate the - // record. The floor dominates the highest fence the lost commit could have granted (1 + 1). - const recovered = reopened.getRecord('session-alpha') - expect(recovered?.lease.runtimeFence).toBe(1) - expect(recovered?.lease.minimumNextFence).toBe(3) - }) - - it.each([ - ['corrupt', ['{ truncated']], - ['missing required collections', ['{"schemaVersion":1,"hostId":"local"}']], - ['invalid operation row', [BAD_OP_STORE]], - ['invalid retired key', [BAD_KEY_STORE]], - ['corrupt in both committed copies', ['{ truncated', '{ also truncated']] - ])('fails closed when the store is %s', async (_name, copies) => { - const filePath = testAgentSessionStoreFilePath(directory) - await mkdir(dirname(filePath), { recursive: true }) - await writeFile(filePath, copies[0]) - if (copies[1]) { - await writeFile(`${filePath}.bak`, copies[1]) - } - await expect(open()).rejects.toThrow('agent_session_store_corrupt') - }) - - it('refuses to write a store written by a newer schema', async () => { + it('refuses to write a store a newer Orca wrote, with the refusal clients print as "update"', async () => { await seedTestAgentSessionStoreFromNewerBuild(directory) const store = await open() expect(store.readOnly).toBe(true) - await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( - 'agent_session_legacy_required' - ) + await expect(store.reserveOwner(reserveRequest())).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) }) }) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 603b5ed02faf..300e48d370d9 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,7 +1,9 @@ +/** Durable single-writer session records and their operation ledger, as rows in the host's chat + * journal database. */ + import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' -/** Durable single-writer session records and their operation ledger. */ import { agentSessionOperationKey, @@ -29,7 +31,6 @@ import type { AgentSessionProviderHandleLink } from '../../shared/agent-session- import { agentSessionScopeKey, type AgentSessionExecutionLocation, - type AgentSessionJournalCheckpoint, type AgentSessionOptionsReplacement, type AgentSessionRecord } from '../../shared/agent-session-record' @@ -37,7 +38,6 @@ import { commitAgentSessionProcessIdentity, evictAgentSessionOwner, proveAgentSessionOwner, - setAgentSessionJournalCheckpoint, type AgentSessionProcessIdentityCommit } from './agent-session-lease-transitions' import { @@ -61,17 +61,11 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session-reservation-admission' -import { - agentSessionStoreRevision, - agentSessionStorePath, - type AgentSessionStoreState -} from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-record-store-file' import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' -import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' -import { - AgentSessionStoreTransactionQueue, - markAgentSessionStoreLeasesUnreconciled -} from './agent-session-store-transaction-queue' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { loadAgentSessionStoreRows } from './agent-session-record-rows' +import { AgentSessionStoreTransactions } from './agent-session-store-transactions' export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -79,46 +73,32 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, export class AgentSessionRecordStore { private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() - private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {} - - static async open(args: { directory: string; hostId: string }): Promise { - const filePath = agentSessionStorePath(args.directory) - const loaded = await loadProtectedAgentSessionStore(filePath, args.hostId) - // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart - // grants no writer on the strength of what the previous process wrote. - const diskRevision = agentSessionStoreRevision(loaded.state) - // The normalized legacy leases reach disk with this store's first transaction rather than a - // write here: a rewrite at open would read as an external change to any other holder of the - // file mid-restart. - markAgentSessionStoreLeasesUnreconciled(loaded.state) - const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( - filePath, - args.hostId, - { ...loaded, needsRewrite: loaded.needsRewrite || loaded.legacyHandoffLeasesNormalized }, - diskRevision + private constructor( + private readonly transactions: AgentSessionStoreTransactions, + readonly hostId: string + ) {} + + /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is. */ + static open(args: { + journalDatabase: JournalHostDatabase + hostId: string + }): AgentSessionRecordStore { + const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + return new AgentSessionRecordStore( + new AgentSessionStoreTransactions(args.journalDatabase, loaded), + args.hostId ) - if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { - await transactions.persistLoadedRewrite() - } - return new AgentSessionRecordStore(transactions) } private get state(): AgentSessionStoreState { return this.transactions.state } + /** A newer Orca wrote the database: every read answers, and every write is refused. */ get readOnly(): boolean { return this.transactions.readOnly } - get recoveredFromBackup(): boolean { - return this.transactions.recoveredFromBackup - } - - get hostId(): string { - return this.state.hostId - } - getRecord = (sessionId: string): AgentSessionRecord | null => this.state.records.get(sessionId) ?? null @@ -129,9 +109,14 @@ export class AgentSessionRecordStore { this.state.records.has(sessionId) ) + /** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ present: this.state.sessionTabs !== null, - sessionIds: this.listVisibleSessionIds() + sessionIds: this.state.sessionTabs + ? this.listVisibleSessionIds() + : (this.state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => + this.state.records.has(sessionId) + ) }) /** The id of the chat tab showing this conversation, if one does. */ @@ -143,13 +128,13 @@ export class AgentSessionRecordStore { * Showing keeps a tab the session already has; `tabId` puts a hidden one back under its old id. */ setSessionTabVisibility(sessionId: string, visible: boolean, tabId?: string): Promise { - return this.transact(() => setAgentSessionTabVisibility(this.state, sessionId, visible, tabId)) + return this.transact((draft) => setAgentSessionTabVisibility(draft, sessionId, visible, tabId)) } /** Shows each session that still has a record, in one write: an index written part way would * read as complete at the next launch and drop the rest. */ showSessionTabs(sessionIds: readonly string[]): Promise { - return this.transact(() => showAgentSessionTabs(this.state, sessionIds)) + return this.transact((draft) => showAgentSessionTabs(draft, sessionIds)) } listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { @@ -162,8 +147,8 @@ export class AgentSessionRecordStore { fence: number, command: NonNullable ): Promise { - return this.transact(() => - commitConversationCommandRecord(this.state, sessionId, fence, command) + return this.transact((draft) => + commitConversationCommandRecord(draft, sessionId, fence, command) ) } @@ -188,8 +173,8 @@ export class AgentSessionRecordStore { isAgentSessionClaimKeyVerifiable(this.state, keyId, now) async reserveOwner(request: AgentSessionReserveRequest): Promise { - return this.transact(() => - commitAgentSessionReservation(this.state, request, AGENT_SESSION_LEASE_TTL_MS) + return this.transact((draft) => + commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS) ) } @@ -225,11 +210,11 @@ export class AgentSessionRecordStore { /** Settle the failed attach and its reservation in one durable transaction. */ settleFailedAcquisition = (args: AgentSessionFailedAcquisitionSettlement) => - this.transact(() => settleFailedAgentSessionAcquisition(this.state, args)) + this.transact((draft) => settleFailedAgentSessionAcquisition(draft, args)) settleFailedPostAcquisitionAttachment = ( args: AgentSessionFailedPostAcquisitionAttachmentSettlement - ) => this.transact(() => settleFailedAgentSessionPostAcquisitionAttachment(this.state, args)) + ) => this.transact((draft) => settleFailedAgentSessionPostAcquisitionAttachment(draft, args)) async renewLease(args: AgentSessionLeaseRenewal): Promise { const [renewed] = await this.renewLeases([args]) @@ -237,8 +222,8 @@ export class AgentSessionRecordStore { } async renewLeases(renewals: readonly AgentSessionLeaseRenewal[]): Promise { - return this.transact(() => - renewAgentSessionLeases(this.state, renewals, AGENT_SESSION_LEASE_TTL_MS) + return this.transact((draft) => + renewAgentSessionLeases(draft, renewals, AGENT_SESSION_LEASE_TTL_MS) ) } @@ -258,38 +243,33 @@ export class AgentSessionRecordStore { return this.mutate(sessionId, transition) } - async setJournalCheckpoint(args: { - sessionId: string - fence: number - checkpoint: AgentSessionJournalCheckpoint - now: number - }): Promise { - return this.mutate(args.sessionId, (record) => - setAgentSessionJournalCheckpoint({ ...args, record }) - ) - } - - /** Adjudicate every lease this host loaded. No lease grants a writer until it appears here. */ + /** + * Adjudicate every lease this host loaded. No lease grants a writer until it appears here. On a + * database a newer Orca wrote, the verdicts are kept in memory only: they are re-derived at every + * start, and none grants a writer there, since every grant is a write. + */ async reconcileOnRestart( args: AgentSessionRestartProbeArgs ): Promise> { const pending = this.listRecords().filter((record) => record.lease.unreconciled) const probes = await collectAgentSessionRestartProbes(pending, args) - return this.transact(() => applyAgentSessionRestartProbes(this.state, probes, args.now)) + return this.transact((draft) => applyAgentSessionRestartProbes(draft, probes, args.now), { + inMemoryWhenReadOnly: true + }) } /** Admits one non-reservation mutation through the durable ledger. */ admitOperation = (args: AgentSessionOperationAdmission): Promise => - this.transact(() => admitAgentSessionOperationInto(this.state, args)) + this.transact((draft) => admitAgentSessionOperationInto(draft, args)) /** Send ids stay global after a caller reconnects under a different identity. */ admitGlobalOperation = ( args: AgentSessionOperationAdmission ): Promise => - this.transact(() => admitAgentSessionGlobalOperationInto(this.state, args)) + this.transact((draft) => admitAgentSessionGlobalOperationInto(draft, args)) admitMutationOperation = (args: AgentSessionMutationOperationAdmission) => - this.transact(() => admitAgentSessionMutationOperation(this.state, args)) + this.transact((draft) => admitAgentSessionMutationOperation(draft, args)) /** The ledger's answer alone, placing nothing; `admitMutationOperation` is the transaction. */ evaluateMutationOperation = (args: AgentSessionMutationOperationAdmission) => @@ -302,36 +282,36 @@ export class AgentSessionRecordStore { callerKey: string operationId: string }): Promise => - this.transact(() => claimAgentSessionOperationInto(this.state, args)) + this.transact((draft) => claimAgentSessionOperationInto(draft, args)) async recordOperationOutcome(args: { callerKey?: string operationId: string outcome: AgentSessionOperationOutcome }): Promise { - await this.transact(() => settleAgentSessionOperationInto(this.state, args)) + await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) async retireClaimKey(keyId: string, now: number): Promise { - await this.transact(() => retireAgentSessionClaimKey(this.state, keyId, now)) + await this.transact((draft) => retireAgentSessionClaimKey(draft, keyId, now)) } private async mutate( sessionId: string, apply: (record: AgentSessionRecord) => AgentSessionRecord ): Promise { - return this.transact(() => { - const record = this.state.records.get(sessionId) + return this.transact((draft) => { + const record = draft.records.get(sessionId) if (!record) { - throw this.isSessionUnreadable(sessionId) + throw draft.unreadableRecords.has(sessionId) ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } const next = apply(record) - this.state.records.set(sessionId, next) + draft.records.set(sessionId, next) return next }) } @@ -343,22 +323,26 @@ export class AgentSessionRecordStore { return () => this.deathEvidenceListeners.delete(listener) } - /** Serialize every mutation against the latest committed disk state. */ - private transact = async (apply: () => T): Promise => { + /** Serializes every mutation. `apply` changes only the draft it is given; readers see the change + * once its rows have committed. */ + private transact = async ( + apply: (draft: AgentSessionStoreState) => T, + options?: { inMemoryWhenReadOnly?: boolean } + ): Promise => { let proven: string[] = [] - const result = await this.transactions.transact(() => { + const result = await this.transactions.transact((draft) => { if (this.deathEvidenceListeners.size === 0) { - return apply() + return apply(draft) } const before = new Map( - [...this.state.records].map(([id, record]) => [id, record.lease.deathEvidence]) + [...draft.records].map(([id, record]) => [id, record.lease.deathEvidence]) ) - const applied = apply() - proven = [...this.state.records] + const applied = apply(draft) + proven = [...draft.records] .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) .map(([id]) => id) return applied - }) + }, options) for (const sessionId of proven) { this.deathEvidenceListeners.forEach((listener) => listener(sessionId)) } diff --git a/src/main/runtime/agent-session-record-unsupported-schema.test.ts b/src/main/runtime/agent-session-record-unsupported-schema.test.ts deleted file mode 100644 index dd0a9dcd28d7..000000000000 --- a/src/main/runtime/agent-session-record-unsupported-schema.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath -} from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -const SESSION_ID = 'session-alpha-1' -let directory: string - -function reserveRequest(): AgentSessionReserveRequest { - return { - sessionId: SESSION_ID, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, - expectedFence: null, - spawnToken: 'spawn-new', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-unsupported-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('unsupported agent session record schema', () => { - it('quarantines without upgrading and keeps the session fail-closed', async () => { - const filePath = agentSessionStorePath(directory) - const unsupported = { ...agentSessionRecordFixture(), schemaVersion: 1 } - const payload = JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: 'local', - records: { [SESSION_ID]: unsupported }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - await Promise.all([writeFile(filePath, payload), writeFile(`${filePath}.bak`, payload)]) - - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - - expect(store.getRecord(SESSION_ID)).toBeNull() - expect(store.isSessionUnreadable(SESSION_ID)).toBe(true) - await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( - 'execution_owner_reconciling' - ) - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records).not.toHaveProperty(SESSION_ID) - expect(persisted.unusableRecords[SESSION_ID]).toMatchObject({ - reason: 'unsupported_schema', - raw: { schemaVersion: 1 } - }) - }) - - it('rejects an ad-hoc store schema without rewriting it', async () => { - const filePath = agentSessionStorePath(directory) - const payload = JSON.stringify({ - schemaVersion: 1, - hostId: 'local', - records: {}, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - await writeFile(filePath, payload) - - await expect(AgentSessionRecordStore.open({ directory, hostId: 'local' })).rejects.toThrow( - 'agent_session_store_corrupt' - ) - await expect(readFile(filePath, 'utf-8')).resolves.toBe(payload) - }) -}) diff --git a/src/main/runtime/agent-session-recovery-publish-fault.test.ts b/src/main/runtime/agent-session-recovery-publish-fault.test.ts deleted file mode 100644 index 9fdbe393334f..000000000000 --- a/src/main/runtime/agent-session-recovery-publish-fault.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DurableFileWrite from '../durable-file-write' -import { - agentSessionStoreRevision, - loadAgentSessionStore, - saveAgentSessionStore, - type AgentSessionStoreState -} from './agent-session-record-store-file' -import { AgentSessionStoreTransactionQueue } from './agent-session-store-transaction-queue' - -const publishFault = vi.hoisted(() => ({ armed: false })) - -vi.mock('../durable-file-write', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - renameDurable: async (tmpPath: string, finalPath: string) => { - if (publishFault.armed) { - publishFault.armed = false - throw new Error('simulated death before primary publish') - } - return actual.renameDurable(tmpPath, finalPath) - } - } -}) - -let root: string -let storePath: string - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-publish-')) - storePath = join(root, 'agent-sessions.json') -}) - -afterEach(async () => { - publishFault.armed = false - await rm(root, { recursive: true, force: true }) -}) - -function state(generation: number): AgentSessionStoreState { - return { - schemaVersion: 2, - hostId: 'local', - records: new Map(), - operations: new Map(), - retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }], - unreadableRecords: new Map(), - sessionTabs: null - } -} - -describe('backup recovery publication', () => { - it('keeps the valid backup when publication fails after a corrupt primary was recovered', async () => { - await saveAgentSessionStore(storePath, state(1), { primaryStatus: 'unusable-or-absent' }) - await saveAgentSessionStore(storePath, state(2), { - primaryStatus: 'validated' - }) - await writeFile(storePath, '{corrupt-primary', 'utf-8') - const knownGoodBackup = await readFile(`${storePath}.bak`, 'utf-8') - - const recovered = await loadAgentSessionStore(storePath, 'local') - expect(recovered.recoveredFromBackup).toBe(true) - expect(recovered.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') - const queue = AgentSessionStoreTransactionQueue.fromLoadedStore( - storePath, - 'local', - recovered, - agentSessionStoreRevision(recovered.state) - ) - - publishFault.armed = true - await expect(queue.persistLoadedRewrite()).rejects.toThrow( - 'simulated death before primary publish' - ) - - expect(await readFile(`${storePath}.bak`, 'utf-8')).toBe(knownGoodBackup) - const afterFault = await loadAgentSessionStore(storePath, 'local') - expect(afterFault.recoveredFromBackup).toBe(true) - expect(afterFault.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') - }) -}) diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts new file mode 100644 index 000000000000..2afdfc13484b --- /dev/null +++ b/src/main/runtime/agent-session-store-draft.ts @@ -0,0 +1,135 @@ +/** + * A transaction applies to a draft of the published store state, and the store publishes the draft + * only once its rows have committed, so no reader ever sees a change that might still roll back. + */ + +import type { + AgentSessionStoreState, + RetiredAgentSessionClaimKey +} from './agent-session-record-store-file' +import { + isReadableAgentSessionStoreOperation, + isReadableAgentSessionStoreRecord, + isReadableAgentSessionStoreTab, + isReadableRetiredAgentSessionClaimKey +} from './agent-session-store-row-rules' +import type { PersistedAgentSessionTab } from './agent-session-tab-table' + +/** Unreadable rows are derived at load and never written, so the draft shares them. */ +export function draftAgentSessionStoreState(state: AgentSessionStoreState): AgentSessionStoreState { + return { + ...state, + records: new Map(state.records), + operations: new Map(state.operations), + retiredClaimKeys: [...state.retiredClaimKeys], + sessionTabs: state.sessionTabs?.clone() ?? null + } +} + +type KeyedRowWrites = { upsert: [key: string, json: string][]; remove: string[] } + +/** Exactly the rows a draft changed, each serialized as it will be stored. */ +export type AgentSessionStoreRowWrites = { + records: KeyedRowWrites + operations: KeyedRowWrites + /** The whole list when it changed, else null. */ + retiredClaimKeys: RetiredAgentSessionClaimKey[] | null + /** The whole index when it changed: `recorded: false` is a store that has never recorded one. */ + sessionTabs: { recorded: boolean; tabs: PersistedAgentSessionTab[] } | null +} + +/** A row as a load parses it back: JSON drops `undefined` members that an in-memory check sees. */ +function serializeChangedRows( + published: ReadonlyMap, + next: ReadonlyMap, + readable: (key: string, written: unknown) => boolean +): KeyedRowWrites { + const writes: KeyedRowWrites = { upsert: [], remove: [] } + let added = 0 + for (const [key, value] of next) { + const prior = published.get(key) + if (prior === value) { + continue + } + const json = JSON.stringify(value) + if (json === undefined || !readable(key, JSON.parse(json))) { + throw new Error('agent_session_store_write_invalid') + } + writes.upsert.push([key, json]) + added += prior === undefined && !published.has(key) ? 1 : 0 + } + // Only a size mismatch can hide a removal, so the common write never walks the published rows. + if (published.size + added !== next.size) { + for (const key of published.keys()) { + if (!next.has(key)) { + writes.remove.push(key) + } + } + } + return writes +} + +function retiredClaimKeysChanged( + published: readonly RetiredAgentSessionClaimKey[], + next: readonly RetiredAgentSessionClaimKey[] +): boolean { + return published.length !== next.length || published.some((entry, index) => entry !== next[index]) +} + +/** + * The rows a draft changed relative to the published state, by identity, or null when it changed + * none. Each changed row is checked as written against the rules a load applies, so a row a later + * load would refuse rejects its own transaction instead of living in memory unreadable. + */ +export function agentSessionStoreDraftRowWrites( + published: AgentSessionStoreState, + draft: AgentSessionStoreState +): AgentSessionStoreRowWrites | null { + const records = serializeChangedRows(published.records, draft.records, (sessionId, written) => + isReadableAgentSessionStoreRecord(sessionId, written) + ) + const operations = serializeChangedRows(published.operations, draft.operations, (key, written) => + isReadableAgentSessionStoreOperation(key, written) + ) + const retiredClaimKeys = retiredClaimKeysChanged( + published.retiredClaimKeys, + draft.retiredClaimKeys + ) + ? draft.retiredClaimKeys + : null + if ( + retiredClaimKeys && + (!retiredClaimKeys.every((entry) => + isReadableRetiredAgentSessionClaimKey(JSON.parse(JSON.stringify(entry))) + ) || + new Set(retiredClaimKeys.map((entry) => entry.keyId)).size !== retiredClaimKeys.length) + ) { + throw new Error('agent_session_store_write_invalid') + } + const tabsChanged = + published.sessionTabs && draft.sessionTabs + ? !published.sessionTabs.equals(draft.sessionTabs) + : published.sessionTabs !== draft.sessionTabs + const tabs = tabsChanged + ? (draft.sessionTabs?.entries() ?? []).map(([tabId, sessionId]) => ({ tabId, sessionId })) + : [] + if (!tabs.every(isReadableAgentSessionStoreTab)) { + throw new Error('agent_session_store_write_invalid') + } + const changed = + records.upsert.length > 0 || + records.remove.length > 0 || + operations.upsert.length > 0 || + operations.remove.length > 0 || + retiredClaimKeys !== null || + tabsChanged + if (!changed) { + return null + } + return { + records, + operations, + retiredClaimKeys, + sessionTabs: tabsChanged ? { recorded: draft.sessionTabs !== null, tabs } : null + } +} diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts new file mode 100644 index 000000000000..6d2f4ddf6b2e --- /dev/null +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -0,0 +1,63 @@ +/** + * What makes one agent-session store row readable. A load applies these to every row it reads; a + * transaction applies them to every row it changed before committing, so the rows this build writes + * are exactly the rows a later load accepts. + */ + +import { + agentSessionOperationKey, + isAgentSessionOperationRow, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record' +import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import type { RetiredAgentSessionClaimKey } from './agent-session-record-store-file' +import type { PersistedAgentSessionTab } from './agent-session-tab-table' + +/** A record row a load keeps in `records` rather than quarantining. */ +export function isReadableAgentSessionStoreRecord( + sessionId: string, + value: unknown +): value is PersistedAgentSessionRecord { + return isPersistedAgentSessionRecord(value) && value.sessionId === sessionId +} + +export function isReadableAgentSessionStoreOperation( + key: string, + value: unknown +): value is AgentSessionOperationRow { + return ( + isAgentSessionOperationRow(value) && + key === agentSessionOperationKey(value.callerKey, value.operationId) + ) +} + +export function isReadableRetiredAgentSessionClaimKey( + entry: unknown +): entry is RetiredAgentSessionClaimKey { + return ( + typeof entry === 'object' && + entry !== null && + 'keyId' in entry && + typeof entry.keyId === 'string' && + entry.keyId.length > 0 && + entry.keyId.length <= 512 && + 'retiredAt' in entry && + typeof entry.retiredAt === 'number' && + Number.isSafeInteger(entry.retiredAt) && + entry.retiredAt >= 0 + ) +} + +/** One chat tab entry; a table is readable when every entry is and no id repeats. */ +export function isReadableAgentSessionStoreTab(entry: unknown): entry is PersistedAgentSessionTab { + return ( + typeof entry === 'object' && + entry !== null && + 'tabId' in entry && + isAgentSessionSurfaceTabId(entry.tabId) && + 'sessionId' in entry && + isAgentSessionId(entry.sessionId) + ) +} diff --git a/src/main/runtime/agent-session-store-serialization.ts b/src/main/runtime/agent-session-store-serialization.ts deleted file mode 100644 index adc93f85c011..000000000000 --- a/src/main/runtime/agent-session-store-serialization.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { serializeAgentSessionTabTable } from './agent-session-tab-table' - -export function serializeAgentSessionStoreState(state: AgentSessionStoreState): string { - const records: Record = Object.create(null) - for (const [sessionId, record] of state.records) { - records[sessionId] = record - } - const serialized: Record = { - schemaVersion: state.schemaVersion, - hostId: state.hostId, - records, - operations: Object.fromEntries(state.operations), - retiredClaimKeys: state.retiredClaimKeys, - unusableRecords: Object.fromEntries(state.unreadableRecords) - } - if (state.sessionTabs) { - Object.assign(serialized, serializeAgentSessionTabTable(state.sessionTabs)) - } - return JSON.stringify(serialized) -} diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts deleted file mode 100644 index 1a4c78e46720..000000000000 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' -import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStoreRevision, - loadAgentSessionStore, - saveAgentSessionStore, - type AgentSessionStoreState, - type LoadedAgentSessionStore -} from './agent-session-record-store-file' -import { withFileTransactionLock } from '../file-transaction-lock' - -/** Latch fields older builds wrote. Nothing reads them, and dropping them keeps a lease this build - * writes back from carrying a stale latch to an older build after a downgrade. */ -type RetiredAgentSessionLeaseFields = { - processlessAt?: unknown - settlementRetryRequired?: unknown - settlementRetryId?: unknown -} - -function markLoadedLeasesUnreconciled(state: AgentSessionStoreState): void { - for (const [sessionId, record] of state.records) { - const lease: AgentSessionLease & RetiredAgentSessionLeaseFields = record.lease - const { - processlessAt: _processlessAt, - settlementRetryRequired: _settlementRetryRequired, - settlementRetryId: _settlementRetryId, - ...current - } = lease - state.records.set(sessionId, { - ...record, - lease: { ...current, unreconciled: true } - }) - } -} - -function mapEntriesMatch(left: ReadonlyMap, right: ReadonlyMap): boolean { - if (left.size !== right.size) { - return false - } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false - } - } - return true -} - -function agentSessionStoreStateChanged( - state: AgentSessionStoreState, - records: ReadonlyMap, - operations: ReadonlyMap, - retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], - unreadableRecords: AgentSessionStoreState['unreadableRecords'], - sessionTabs: AgentSessionStoreState['sessionTabs'] -): boolean { - return ( - !mapEntriesMatch(state.records, records) || - !mapEntriesMatch(state.operations, operations) || - !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || - (state.sessionTabs && sessionTabs - ? !state.sessionTabs.equals(sessionTabs) - : state.sessionTabs !== sessionTabs) || - state.retiredClaimKeys.length !== retiredClaimKeys.length || - state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) - ) -} - -export class AgentSessionStoreTransactionQueue { - private queue: Promise = Promise.resolve() - private diskRecoveredFromBackup: boolean - - constructor( - private readonly filePath: string, - readonly hostId: string, - readonly readOnly: boolean, - readonly recoveredFromBackup: boolean, - private diskStoreFound: boolean, - public state: AgentSessionStoreState, - private diskRevision: string, - private needsRewrite: boolean - ) { - this.diskRecoveredFromBackup = recoveredFromBackup - } - - static fromLoadedStore( - filePath: string, - hostId: string, - loaded: LoadedAgentSessionStore, - diskRevision: string - ): AgentSessionStoreTransactionQueue { - return new AgentSessionStoreTransactionQueue( - filePath, - hostId, - loaded.readOnly, - loaded.recoveredFromBackup, - loaded.storeFound, - loaded.state, - diskRevision, - loaded.needsRewrite - ) - } - - transact(apply: () => T): Promise { - const run = this.queue.then(() => - withFileTransactionLock(this.filePath, async () => { - if (this.readOnly) { - throw new Error('agent_session_legacy_required') - } - await this.refreshExternallyChangedState() - const records = new Map(this.state.records) - const operations = new Map(this.state.operations) - const retiredClaimKeys = [...this.state.retiredClaimKeys] - const unreadableRecords = new Map(this.state.unreadableRecords) - const sessionTabs = this.state.sessionTabs?.clone() ?? null - try { - // The lost commit may have granted a higher fence than the backup records show. Rather - // than refuse forever, raise every recovered fence clear of anything that commit could - // have minted, then continue in the same transaction. - const recovering = this.diskRecoveredFromBackup - if (recovering) { - raiseAgentSessionFencesAfterBackupRecovery(this.state) - } - const result = apply() - if ( - !recovering && - !this.needsRewrite && - !agentSessionStoreStateChanged( - this.state, - records, - operations, - retiredClaimKeys, - unreadableRecords, - sessionTabs - ) - ) { - return result - } - await saveAgentSessionStore(this.filePath, this.state, { - primaryStatus: this.diskStoreFound && !recovering ? 'validated' : 'unusable-or-absent' - }) - this.state.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION - this.diskRevision = agentSessionStoreRevision(this.state) - this.diskRecoveredFromBackup = false - this.diskStoreFound = true - this.needsRewrite = false - return result - } catch (error) { - this.state.records = records - this.state.operations = operations - this.state.retiredClaimKeys = retiredClaimKeys - this.state.unreadableRecords = unreadableRecords - this.state.sessionTabs = sessionTabs - throw error - } - }) - ) - this.queue = run.catch(() => {}) - return run - } - - persistLoadedRewrite(): Promise { - return this.transact(() => undefined) - } - - private async refreshExternallyChangedState(): Promise { - const loaded = await loadAgentSessionStore(this.filePath, this.hostId) - if (this.diskStoreFound && !loaded.storeFound) { - throw new Error('agent_session_store_corrupt') - } - this.diskStoreFound ||= loaded.storeFound - const diskRevision = agentSessionStoreRevision(loaded.state) - this.diskRecoveredFromBackup = loaded.recoveredFromBackup - if (diskRevision === this.diskRevision) { - this.needsRewrite ||= loaded.needsRewrite - return - } - if (loaded.readOnly) { - throw new Error('agent_session_legacy_required') - } - markLoadedLeasesUnreconciled(loaded.state) - this.state = loaded.state - this.diskRevision = diskRevision - this.needsRewrite = loaded.needsRewrite - } -} - -export function markAgentSessionStoreLeasesUnreconciled(state: AgentSessionStoreState): void { - markLoadedLeasesUnreconciled(state) -} diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts new file mode 100644 index 000000000000..5ab6cad9e028 --- /dev/null +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -0,0 +1,201 @@ +// How a record-store write reaches the chat database: only the rows it changed, each checked with +// the rules a load applies, never inside a caller's own transaction, and never in memory unless the +// rows committed. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' + +const NOW = 1_800_000_000_000 + +let root: string +let counter = 0 + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-session-store-transactions-')) +}) + +afterEach(async () => { + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +/** Reserve, observe the spawn, prove the handle: a chat with a live writer. */ +async function liveChat( + store: AgentSessionRecordStore, + sessionId: string +): Promise { + counter += 1 + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: `spawn-${counter}`, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${String(counter).padStart(32, '0')}`, + fingerprint: `fp-${counter}` + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId, + fence, + process: { + hostId: 'local', + pid: 4000 + counter, + processStartTimeMs: NOW, + spawnToken: `spawn-${counter}` + }, + now: NOW + }) + return store.proveOwner({ + sessionId, + fence, + link: { + linkId: `link-${counter}`, + handle: { provider: 'claude', sessionId: `provider-${counter}`, leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +function totalChanges(): number { + return Number( + openTestJournalHostDatabase(root).db.prepare('SELECT total_changes() AS n').get()?.n ?? 0 + ) +} + +describe('writing a transaction', () => { + it('writes one record row for a renewal of one chat among several', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + await liveChat(store, 'chat-b-0002') + const renewed = await liveChat(store, 'chat-c-0003') + const before = totalChanges() + + await store.renewLease({ + sessionId: 'chat-c-0003', + fence: renewed.lease.runtimeFence, + childProbe: { outcome: 'identity-matched', matchedOn: ['spawn-token'] }, + now: NOW + 10_000 + }) + + expect(totalChanges() - before).toBe(1) + expect( + (await openTestAgentSessionRecordStore(root)).getRecord('chat-c-0003')?.lease.lastRenewedAt + ).toBe(NOW + 10_000) + }) + + it('writes nothing for a transaction that changes nothing', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const before = totalChanges() + + await store.renewLeases([]) + + expect(totalChanges()).toBe(before) + }) + + // Every mutation lands after the caller's frame, so one made inside an open journal transaction + // on the same connection never nests a BEGIN there. + it('runs a store call made inside an open journal transaction after that transaction', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + let pending: Promise | null = null + + openTestJournalHostDatabase(root).transaction(() => { + pending = store.setConversationName('chat-a-0001', 'renamed') + }) + + await expect(pending).resolves.toMatchObject({ conversationName: 'renamed' }) + expect((await readPersistedTestAgentSessionStore(root)).records['chat-a-0001']).toMatchObject({ + conversationName: 'renamed' + }) + }) +}) + +describe('a change a load would refuse', () => { + it('rejects a tab id that could not prefix a pane key, and keeps memory and rows as they were', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const persisted = await readPersistedTestAgentSessionStore(root) + + await expect( + store.setSessionTabVisibility('chat-a-0001', true, 'agent-session:chat-a-0001') + ).rejects.toThrow('agent_session_store_write_invalid') + + expect(store.getSessionTabId('chat-a-0001')).toBeNull() + expect(store.getVisibleSessionTabIndex().present).toBe(false) + expect(await readPersistedTestAgentSessionStore(root)).toEqual(persisted) + }) + + it('rejects a record whose handle chain names a link twice', async () => { + const store = await openTestAgentSessionRecordStore(root) + const before = await liveChat(store, 'chat-a-0001') + + await expect( + store.transitionHandoff('chat-a-0001', (record) => ({ + ...record, + providerHandleChain: [...record.providerHandleChain, ...record.providerHandleChain] + })) + ).rejects.toThrow('agent_session_store_write_invalid') + + expect(store.getRecord('chat-a-0001')).toBe(before) + expect( + (await openTestAgentSessionRecordStore(root)).getRecord('chat-a-0001')?.providerHandleChain + ).toHaveLength(1) + }) +}) + +describe('rows in memory', () => { + it('throws on a change made in place, which the row diff would never write', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const loaded = (await openTestAgentSessionRecordStore(root)).getRecord('chat-a-0001') + + expect(() => Object.assign(loaded?.lease ?? {}, { runtimeFence: 99 })).toThrow(TypeError) + expect(() => + Object.assign(store.getRecord('chat-a-0001') ?? {}, { conversationName: 'x' }) + ).toThrow(TypeError) + }) +}) + +describe('after the database closes', () => { + // Late settlement at quit lands here: the caller reports it, and nothing in memory moves. + it('refuses a write with journal_closed and leaves memory as it was', async () => { + const store = await openTestAgentSessionRecordStore(root) + const before = await liveChat(store, 'chat-a-0001') + closeTestJournalHostDatabases() + + await expect(store.setConversationName('chat-a-0001', 'late')).rejects.toMatchObject({ + code: 'journal_closed' + }) + expect(store.getRecord('chat-a-0001')).toBe(before) + }) +}) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts new file mode 100644 index 000000000000..879ff4a160e8 --- /dev/null +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -0,0 +1,137 @@ +// Every agent-session store mutation: draft the published state, apply, check and write exactly the +// changed rows in one journal transaction, then publish the draft. A throw anywhere discards the +// draft, so memory never holds a change the database does not. +// +// Why a promise queue although the body is synchronous: a store call must never run its BEGIN in +// the caller's frame. A caller inside an open journal transaction on the same connection would nest +// a BEGIN, which `runJournalTransaction` does not support. The queue also keeps the FIFO order and +// the async boundary every awaiting caller was written against. + +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { writeAgentSessionStoreRows } from './agent-session-record-rows' +import { + agentSessionStoreDraftRowWrites, + draftAgentSessionStoreState, + type AgentSessionStoreRowWrites +} from './agent-session-store-draft' + +// Why: rows are diffed by identity, so a row changed in place would never be written. Tests and +// development builds make that a TypeError; packaged builds skip the walk. +const FREEZE_ROWS = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' + +function deepFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) { + return + } + Object.freeze(value) + for (const child of Object.values(value)) { + deepFreeze(child) + } +} + +function freezeRows( + state: AgentSessionStoreState, + writes: AgentSessionStoreRowWrites | null +): void { + if (!FREEZE_ROWS) { + return + } + const records = writes ? writes.records.upsert.map(([key]) => key) : state.records.keys() + for (const sessionId of records) { + deepFreeze(state.records.get(sessionId)) + } + const operations = writes ? writes.operations.upsert.map(([key]) => key) : state.operations.keys() + for (const key of operations) { + deepFreeze(state.operations.get(key)) + } + if (!writes || writes.retiredClaimKeys) { + state.retiredClaimKeys.forEach(deepFreeze) + } +} + +/** What every write gets on a database a newer Orca wrote: the refusal clients print as "update". */ +function readOnlyStoreRefusal(): Error { + return journalOpenRefusalError( + new AgentSessionJournalError( + 'journal_read_only', + 'the chat records were saved by a newer Orca; this host reads them and never writes' + ) + ) +} + +type StagedStoreTransaction = { + result: T + writes: AgentSessionStoreRowWrites | null + /** Publishes the draft. Only once its rows have committed. */ + adopt: () => void +} + +export class AgentSessionStoreTransactions { + private queue: Promise = Promise.resolve() + private published: AgentSessionStoreState + + constructor( + private readonly journalDatabase: JournalHostDatabase, + loaded: AgentSessionStoreState + ) { + freezeRows(loaded, null) + this.published = loaded + } + + /** The committed state. A transaction in flight never shows here until its rows have landed. */ + get state(): AgentSessionStoreState { + return this.published + } + + get readOnly(): boolean { + return this.journalDatabase.readOnly + } + + /** + * `apply` changes only the draft it is given. On a database a newer Orca wrote every transaction + * is refused, except one marked `inMemoryWhenReadOnly`: it is published without a write, for a + * verdict re-derived at every start. + */ + transact( + apply: (draft: AgentSessionStoreState) => T, + options: { inMemoryWhenReadOnly?: boolean } = {} + ): Promise { + const run = this.queue.then(() => this.commit(apply, options.inMemoryWhenReadOnly === true)) + this.queue = run.catch(() => {}) + return run + } + + private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { + const readOnly = this.journalDatabase.readOnly + if (readOnly && !inMemoryWhenReadOnly) { + throw readOnlyStoreRefusal() + } + const staged = this.stage(apply) + const writes = staged.writes + if (writes && !readOnly) { + this.journalDatabase.transaction((db) => writeAgentSessionStoreRows(db, writes)) + } + staged.adopt() + return staged.result + } + + private stage(apply: (draft: AgentSessionStoreState) => T): StagedStoreTransaction { + const published = this.published + const draft = draftAgentSessionStoreState(published) + const result = apply(draft) + const writes = agentSessionStoreDraftRowWrites(published, draft) + return { + result, + writes, + adopt: () => { + if (writes) { + freezeRows(draft, writes) + this.published = draft + } + } + } + } +} diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 72551d563cab..15ace0af7618 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -137,30 +137,22 @@ export function showAgentSessionTabs( ): void { for (const sessionId of sessionIds) { if (state.records.has(sessionId)) { - setAgentSessionTabVisibility(state, sessionId, true) + setAgentSessionTabVisibility( + state, + sessionId, + true, + state.unrecordedSessionTabs?.tabIdFor(sessionId) + ) } } } export type PersistedAgentSessionTab = { tabId: string; sessionId: string } -export function serializeAgentSessionTabTable(table: AgentSessionTabTable): { - sessionTabs: PersistedAgentSessionTab[] - visibleSessionIds: string[] -} { - return { - sessionTabs: table.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })), - // Written for older builds, which restore tabs from this list; never read beside the table. - visibleSessionIds: table.sessionIds() - } -} - /** - * Reads the persisted table, or seeds it from what older builds wrote: the visible session list and, - * for a chat created by a build that recorded one, the tab id on its record. That record field is - * read here and nowhere else, and only when the file carries no table. - * - * Deterministic on purpose: a parsed store must hash the same on every read of the same bytes. + * Reads the records file's table, or seeds it from what older builds wrote: the visible session list + * and, for a chat created by a build that recorded one, the tab id on its record. That record field + * is read here and nowhere else, and only when the file carries no table. */ export function parseAgentSessionTabTable( file: { sessionTabs?: unknown; visibleSessionIds?: unknown }, diff --git a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts deleted file mode 100644 index cde4091d7ebf..000000000000 --- a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -// Unreadable session records: quarantine when nothing can vouch for the session, salvage -// when the previous committed state can. - -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -let directory: string -let counter = 0 - -function operationId(): string { - counter += 1 - return `${NOW}-${String(counter) - .padStart(32, '0') - .replaceAll(/[^0-9a-f]/g, '0')}` -} - -function reserveRequest( - overrides: Partial = {} -): AgentSessionReserveRequest { - return { - sessionId: 'session-alpha', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'no answer' }, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, - now: NOW, - ...overrides - } -} - -async function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) -} - -/** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ -async function establishOwner(store: AgentSessionRecordStore): Promise { - const reserved = await store.reserveOwner(reserveRequest()) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: 'session-alpha', - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: reserved.record.lease.reservedSpawnToken ?? 'spawn-a' - }, - now: NOW - }) - return store.proveOwner({ - sessionId: 'session-alpha', - fence, - link: { - linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW - }) -} - -async function corruptPrimaryRecord(): Promise { - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - raw.records['session-alpha'].lease.runtimeFence = 'not-a-number' - await writeFile(filePath, JSON.stringify(raw)) - return filePath -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-salvage-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('unreadable session records', () => { - it('quarantines an unreadable record with no committed copy and refuses to own it', async () => { - const first = await open() - await establishOwner(first) - const filePath = await corruptPrimaryRecord() - // No previous committed state survives, so nothing can vouch for the session. - await rm(`${filePath}.bak`, { force: true }) - - const reopened = await open() - expect(reopened.getRecord('session-alpha')).toBeNull() - expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) - await expect(reopened.reserveOwner(reserveRequest())).rejects.toThrow( - 'execution_owner_reconciling' - ) - // The row stays verbatim inside an explicit unusable envelope. - await reopened.retireClaimKey('key-2', NOW) - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records).not.toHaveProperty('session-alpha') - expect(persisted.unusableRecords['session-alpha']).toMatchObject({ - reason: 'current_shape_invalid', - raw: { lease: { runtimeFence: 'not-a-number' } } - }) - }) - - it('salvages the last committed copy of a record the primary retains as unreadable', async () => { - const first = await open() - await establishOwner(first) - // One more commit leaves the proven-owner record in the backup file. - await first.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 1 }, - now: NOW - }) - const filePath = await corruptPrimaryRecord() - - const reopened = await open() - // The previous committed state vouches for the session; its lease is re-adjudicated - // like any other, and the unreadable bytes stay quarantined verbatim. - expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ - runtimeFence: 1, - claimStatus: 'live' - }) - expect(reopened.recoveredFromBackup).toBe(false) - expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) - await reopened.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - expect(reopened.getRecord('session-alpha')?.lease.claimStatus).toBe('released') - - // Ownership is reachable again instead of refused with execution_owner_reconciling. - const reserved = await reopened.reserveOwner( - reserveRequest({ expectedFence: 2, spawnToken: 'spawn-b' }) - ) - expect(reserved.record.lease.claimStatus).toBe('reserved') - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records['session-alpha'].lease.claimStatus).toBe('reserved') - expect(persisted.unusableRecords['session-alpha']).toMatchObject({ - reason: 'current_shape_invalid' - }) - - // Salvage only fills gaps: with the live record readable again, a reload must never - // let the stale backup copy clobber it. - const reloaded = await open() - expect(reloaded.getRecord('session-alpha')?.lease).toMatchObject({ - claimStatus: 'reserved', - runtimeFence: 3 - }) - }) -}) diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 568083bf177f..b0a584182e07 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -20,7 +20,11 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi const host = getStructuredAgentSessionHost() if (host) { if (typeof host.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(tab.sessionId, false) + // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a + // failing disk) is reported, and the tab still closes. + await host.setSessionTabVisibility(tab.sessionId, false).catch((error: unknown) => { + console.warn('[structured-agent-session] recording a closed chat tab failed', error) + }) } } // Retire durable visibility and the runtime snapshot before stopping the provider. diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 10dd920b253b..f8845221e66a 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -60,7 +60,11 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds + // Unrecorded, the profile's chats join the tabs chats opened while the import was owed left. + // First: after a /clear the profile's chat would take their tab id, so seeds hit tabIdTaken. + const targets = persistedVisibleIndex.present + ? persistedVisibleIndex.sessionIds + : [...new Set([...persistedVisibleIndex.sessionIds, ...profileIds])] await host?.restoreReadableSessions(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { @@ -92,9 +96,12 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable - // No host means no one can say which chats exist; with none on disk, empty is the answer. + // No host, or one still owed the records file's chats, means no one can say which chats exist; + // with none on disk, empty is the answer. + const importOwed = + typeof host?.legacyRecordImportOwed === 'function' && host.legacyRecordImportOwed() this.structuredAgentSessionInventoryUnverifiable = - !host && this.hasPersistedStructuredAgentSessionStore() + (!host || importOwed) && this.hasPersistedStructuredAgentSessionStore() // This restore published quietly; subscribers still hold the frames that said "cannot tell". if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { this.notifyMobileSessionTabSnapshots() @@ -113,11 +120,13 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility( - input.sessionId, - true, - ...(input.tabId ? [input.tabId] : []) - ) + // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a + // failing disk) is reported, and the tab still opens. + await host + .setSessionTabVisibility(input.sessionId, true, ...(input.tabId ? [input.tabId] : [])) + .catch((error: unknown) => { + console.warn('[structured-agent-session] recording an opened chat tab failed', error) + }) } this.projectStructuredAgentSessionTab(input) } diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index bf90e650d534..24d0a80b3d1d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -7,7 +7,7 @@ * harness rather than of the guard. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,9 +26,9 @@ import { } from '../../../../shared/agent-session-operation-ledger' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { + editPersistedTestAgentSessionStore, openTestAgentSessionRecordStore, - readPersistedTestAgentSessionStore, - testAgentSessionStoreFilePath + readPersistedTestAgentSessionStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -420,10 +420,8 @@ describe('the recorded row stays readable by a build that predates it', () => { const outcome = Object.values(file.operations)[0].outcome // The ratchet, and the reason this is not a new status arm or an optional `sessionId`: a build - // without `launch` validates a row by these two fields, one row it rejects returns null for the - // whole file, and the schema version cannot be bumped to excuse it — a store is unreadable to - // any build whose version is higher than the file's. A downgrade must skip what it cannot - // understand, not lose every lease. + // without `launch` validates a row by these two fields and drops a row it rejects, losing its + // replay. A downgrade must skip only what it cannot understand. expect(outcome.status).toBe('succeeded') expect(typeof outcome.sessionId).toBe('string') expect(outcome.launch).toMatchObject({ outcome: { kind: 'terminal' } }) @@ -431,24 +429,17 @@ describe('the recorded row stays readable by a build that predates it', () => { }) describe('an unreadable launch payload costs one replay, never the store', () => { - /** The whole file, primary and backup: `loadAgentSessionStore` falls through to the backup, and - * the backup is a copy of the validated primary, so both carry the same payload in real life. */ + /** The recorded operation row, as a build that wrote another payload shape leaves it. */ async function rewriteRecordedLaunch(payload: unknown): Promise { - const path = testAgentSessionStoreFilePath(directory) - const file: { operations: Record }> } = JSON.parse( - await readFile(path, 'utf-8') - ) - const row = Object.values(file.operations)[0] - row.outcome.launch = payload - const written = JSON.stringify(file) - await writeFile(path, written) - await writeFile(`${path}.bak`, written) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + const row: { outcome: Record } = Object.values(persisted.operations)[0] + row.outcome.launch = payload + }) } - it('still admits the row, because one rejected row makes the whole file unparseable', () => { + it('still admits the row, because a load drops a row it rejects', () => { // The ratchet. `isAgentLaunchResult` mirrors a result type by hand, so a field tightened there - // would reject rows this same build wrote — and a primary and backup that both fail to parse - // raise `agent_session_store_corrupt`, taking every lease in the profile with them. + // would reject rows this same build wrote and lose their replay. expect( isAgentSessionOperationRow({ callerKey: 'device-1', diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 29623f623cd0..c752bddd4582 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -3,7 +3,7 @@ * the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -27,7 +27,7 @@ import { setStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore, - testAgentSessionStoreFilePath + seedTestAgentSessionStoreFromNewerBuild } from '../../agent-session-record-store-test-harness' import { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' @@ -193,6 +193,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() await host?.flushAllStreamedEvents() setStructuredAgentSessionHost(null) await rm(directory, { recursive: true, force: true }) @@ -283,27 +284,6 @@ describe('a chat tab across /clear', () => { sessionIds: [replacement] }) }) - - it('gives a reopened cleared conversation the same id when an older build drops the table', async () => { - await createChat(HOST_TEST_SESSION) - const first = await clear(HOST_TEST_SESSION) - await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) - const current = await clear(first) - const reopenedTab = store.getSessionTabId(HOST_TEST_SESSION) - expect(reopenedTab).not.toBeNull() - await host.flushAllStreamedEvents() - - // An older build rewrites the file from what it read, which drops the table. - const file = testAgentSessionStoreFilePath(directory) - const raw = JSON.parse(await readFile(file, 'utf-8')) - expect(raw.sessionTabs).toHaveLength(2) - delete raw.sessionTabs - await writeFile(file, JSON.stringify(raw)) - - await openHost() - expect(store.getSessionTabId(current)).toBe(SOURCE_TAB) - expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(reopenedTab) - }) }) describe('session tab mutations from other clients, unchanged by the table', () => { @@ -408,3 +388,47 @@ describe('a create that reserves its tab', () => { }) }) }) + +describe('a chat tab over records a newer Orca wrote', () => { + it('opens a closed chat from history for reading', async () => { + await createChat(HOST_TEST_SESSION) + await host.close(HOST_TEST_SESSION) + await host.setSessionTabVisibility(HOST_TEST_SESSION, false) + await host.flushAllStreamedEvents() + await seedTestAgentSessionStoreFromNewerBuild(directory) + await openHost() + expect(store.readOnly).toBe(true) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ + ok: true, + result: { ok: true, readable: true } + }) + + expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording an opened chat tab failed', + expect.anything() + ) + }) +}) + +describe('a chat tab whose restore index cannot be written', () => { + // The index is bookkeeping: the chat is created and its tab opens, but no restart restores it. + it('creates the chat and opens its tab, reporting the index write', async () => { + const failure = new Error('disk I/O error') + vi.spyOn(store, 'setSessionTabVisibility').mockRejectedValue(failure) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const created = await createChat(HOST_TEST_SESSION) + + expect(created).toMatchObject({ ok: true, value: { sessionId: HOST_TEST_SESSION } }) + expect(created.ok ? created.value.tabId : null).toBeUndefined() + expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording an opened chat tab failed', + failure + ) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() + }) +}) diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index 689ac3df969c..d05c9a78422a 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -122,7 +122,7 @@ describe('a process whose journal will not open', () => { const logged = String(warn.mock.calls[0]?.[1]) expect(logged).toContain(path) expect(logged).toContain('unreleased development build') - expect(logged).toContain('move the file aside') + expect(logged).toContain('Moving the file aside') expect(await digest(path)).toBe(before) }) @@ -353,19 +353,19 @@ describe('startup and other non-chat work without a structured host', () => { ).resolves.toBeUndefined() await expect( assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { - throw new Error('the record store would not open') + throw new Error('the host would not construct') }) - ).rejects.toThrow('the record store would not open') + ).rejects.toThrow('the host would not construct') expect(gateRefusal().reason).toBe('journalCorrupt') }) it('still fails on an install error that refuses nothing', async () => { const { runtime, refreshPtyRecords } = startupRuntime(async () => { - throw new Error('the record store would not open') + throw new Error('the host would not construct') }) await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).rejects.toThrow( - 'the record store would not open' + 'the host would not construct' ) expect(refreshPtyRecords).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index 53720659ec41..4b00cdebda60 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -57,12 +57,13 @@ describe('an install that fails after opening the chat journal', () => { mocks.failWiring.mockReturnValueOnce(true) await expect(install()).rejects.toThrow('model catalog wiring failed') - const failed = open.mock.results[0]?.value + const failed = await open.mock.results[0]?.value expect(failed).toBeInstanceOf(JournalHostDatabase) expect(failed.isClosed).toBe(true) await expect(install()).resolves.toBeDefined() expect(open).toHaveBeenCalledTimes(2) - expect(open.mock.results[1]?.value.isClosed).toBe(false) + const reopened = await open.mock.results[1]?.value + expect(reopened?.isClosed).toBe(false) }) }) diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index efc2f786c99e..cf41fdb1dfe0 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -457,6 +457,8 @@ describe('a structured codex session over agentSession.*', () => { CODEX_HOME: '/home/dev/.codex' }) const store = await readPersistedTestAgentSessionStoreText(root) + // The record this create wrote, so the checks below read the runtime's own rows. + expect(store).toContain('/home/dev/.codex') expect(store).not.toContain('EXAMPLE_GATEWAY_TOKEN') expect(store).not.toContain('"launchEnv"') const stream = await subscribe('sub-first-send') diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 6972a18a7960..6dd9d964d48d 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -1,7 +1,13 @@ -// Opening the chat journal database for the host install. +// Opening the chat journal database for the host install. The open that migrates it to version 4 +// first reads the records file it replaces, so the copy runs in the migration's transaction. import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { + legacyAgentSessionRecordImport, + readLegacyAgentSessionRecords, + type LegacyAgentSessionRecordImportReport +} from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. @@ -21,11 +27,19 @@ function logOpenFailureOnce(error: unknown): void { /** The journal database. A refusal is recorded for the gate and thrown to the caller; the next * install tries again. */ -export function openStructuredAgentSessionJournalDatabase( +export async function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string -): JournalHostDatabase { + hostId: string + onLegacyRecordImportReport: (report: LegacyAgentSessionRecordImportReport) => void +}): Promise { try { - const opened = JournalHostDatabase.open(stateDirectory) + const opened = await JournalHostDatabase.open(args.stateDirectory, async () => + legacyAgentSessionRecordImport( + await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), + args.hostId, + args.onLegacyRecordImportReport + ) + ) recordStructuredAgentSessionHostInstallRefusal(null) lastLoggedOpenFailure = null return opened diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts new file mode 100644 index 000000000000..551fab477c41 --- /dev/null +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -0,0 +1,171 @@ +// The records file's one-time copy never keeps the host from installing. A file no read will make +// usable is reported and left alone; a read that can clear leaves the copy owed, and until a later +// launch makes it, the chat list says it cannot tell rather than "none". + +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import Database from '../sqlite/sync-database' +import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const NOW = 1_800_000_000_000 +const IMPORTED = 'session-alpha-1' +const CREATED_WHILE_OWED = 'session-new-0001' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-record-import-install-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await chmod(legacyAgentSessionStorePath(root), 0o600).catch(() => undefined) + await rm(root, { recursive: true, force: true }) +}) + +async function writeLegacy(contents: string): Promise { + await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) + await writeFile(legacyAgentSessionStorePath(root), contents) +} + +const legacyFile = (): string => + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [IMPORTED]: agentSessionRecordFixture() }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + +function databaseVersion(): number { + const db = new Database(journalDatabasePath(root)) + try { + return journalPragmaNumber(db, 'user_version') + } finally { + db.close() + } +} + +/** The runtime at startup, rooted at `root`, with its PTY daemon stubbed. */ +function startupRuntime(onError: (input: { scope: string; error: unknown }) => void) { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + structuredAgentSessionInventoryUnverifiable: boolean + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + onError + }) + internal.refreshMobileSessionPtyRecords = async () => new Set() + return { + runtime, + host: () => internal.ensureStructuredAgentSessionHost(), + unverifiable: () => internal.structuredAgentSessionInventoryUnverifiable + } +} + +function importReports(onError: ReturnType): unknown[] { + return onError.mock.calls + .map(([input]) => input) + .filter((input) => input.scope === 'structured-agent-session-record-import') + .map((input) => input.error) +} + +// A read that can clear: the file's own permissions, as a locked-down or mid-restore profile has. +it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'installs while the file cannot be read, says it cannot tell, and copies it once it can', + async () => { + await writeLegacy(legacyFile()) + await chmod(legacyAgentSessionStorePath(root), 0o000) + const onError = vi.fn() + const first = startupRuntime(onError) + + await first.runtime.restoreStructuredAgentSessionTabs() + const host = await first.host() + + expect(importReports(onError)).toEqual([ + { + kind: 'unavailable', + error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) + } + ]) + expect(host.legacyRecordImportOwed()).toBe(true) + expect(first.unverifiable()).toBe(true) + expect(databaseVersion()).toBe(3) + // A chat started meanwhile lives in the database and keeps its row through the copy. + await host.deps.store.reserveOwner({ + sessionId: CREATED_WHILE_OWED, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: 'spawn-new', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'0'.repeat(31)}1`, + fingerprint: 'fp-new' + }, + now: NOW + }) + await stopStructuredAgentSessionRuntime() + + await chmod(legacyAgentSessionStorePath(root), 0o600) + const second = startupRuntime(vi.fn()) + await second.runtime.restoreStructuredAgentSessionTabs() + const relaunched = await second.host() + + expect(relaunched.legacyRecordImportOwed()).toBe(false) + expect(second.unverifiable()).toBe(false) + expect(databaseVersion()).toBe(4) + expect(relaunched.deps.store.getRecord(IMPORTED)).not.toBeNull() + expect(relaunched.deps.store.getRecord(CREATED_WHILE_OWED)).not.toBeNull() + } +) + +it('installs over a file no read will make usable, reports it once, and leaves it untouched', async () => { + await writeLegacy('{ truncated') + const onError = vi.fn() + const { runtime, host } = startupRuntime(onError) + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + + expect(importReports(onError)).toEqual([ + { kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) } + ]) + expect((await host()).legacyRecordImportOwed()).toBe(false) + expect(databaseVersion()).toBe(4) + await stopStructuredAgentSessionRuntime() + expect(await readFile(legacyAgentSessionStorePath(root), 'utf-8')).toBe('{ truncated') +}) diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index c5aa604cb65e..0b65c556db85 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -7,8 +7,8 @@ import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { openTestAgentSessionRecordStore, - seedTestAgentSessionRecordStore, - testAgentSessionStoreFilePath + readPersistedTestAgentSessionStore, + seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' @@ -123,9 +123,9 @@ describe('structured session rollback compatibility', () => { await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( 'durable-journal-fixture\n' ) - expect(JSON.parse(await readFile(testAgentSessionStoreFilePath(root), 'utf8'))).toMatchObject({ - visibleSessionIds: [SESSION] - }) + expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ + { tabId: expect.any(String), sessionId: SESSION } + ]) await reloaded.setSessionTabVisibility(SESSION, false) const afterClose = await openTestAgentSessionRecordStore(root) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 3e2a1f14b869..e1d37c452a1e 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -1,6 +1,6 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionClaimStatus, @@ -10,6 +10,15 @@ import type { } from '../../shared/agent-session-record' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + NO_LEGACY_JOURNAL_RECORDS, + type JournalLegacyRecordImport +} from '../native-chat/agent-session-journal/journal-database' +import { + JournalHostDatabase, + journalDatabasePath +} from '../native-chat/agent-session-journal/journal-host-database' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -54,35 +63,79 @@ const OWNER: AgentSessionProcessIdentity = { const deadProbe = () => vi.fn(async () => ({ outcome: 'pid-absent' }) as const) describe('structured agent-session store presence', () => { - it('stops after finding the durable primary store', () => { - const fileExists = vi.fn(() => true) + let profile: string + afterEach(async () => { + if (profile) { + await rm(profile, { recursive: true, force: true }) + profile = '' + } + }) - expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) - expect(fileExists).toHaveBeenCalledOnce() - expect(fileExists).toHaveBeenCalledWith( - join('/profile', 'agent-sessions', 'agent-sessions.json') - ) + async function openProfileDatabase( + legacyRecords: JournalLegacyRecordImport = NO_LEGACY_JOURNAL_RECORDS + ): Promise { + profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) + return JournalHostDatabase.openWith(profile, legacyRecords) + } + + async function writeRecordsFile(): Promise { + const filePath = legacyAgentSessionStorePath(profile) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, '{}') + } + + // Every host install creates the database, chats or not; startup restore must not wait on one. + it('reports a profile whose database holds no chat absent', async () => { + ;(await openProfileDatabase()).close() + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false) }) - it('checks the durable backup when the primary store is absent', () => { + it('reports a chat record in the database', async () => { + const database = await openProfileDatabase() + database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run('session-1', '{}') + database.close() + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) + }) + + it('lets the records file answer while the database still owes its copy', async () => { + ;(await openProfileDatabase({ owed: true })).close() + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false) + + await writeRecordsFile() + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) + }) + + it('reports a database it cannot read present', async () => { + profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) + await writeFile(journalDatabasePath(profile), 'not a database') + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) + }) + + // A profile from before the records moved into the database still holds a chat to import. + it('checks the records file and its backup when the database is absent', () => { const fileExists = vi.fn((path: string) => path.endsWith('.bak')) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) expect(fileExists).toHaveBeenNthCalledWith( - 1, + 2, join('/profile', 'agent-sessions', 'agent-sessions.json') ) expect(fileExists).toHaveBeenNthCalledWith( - 2, + 3, join('/profile', 'agent-sessions', 'agent-sessions.json.bak') ) }) - it('reports a fresh profile absent after two bounded presence checks', () => { + it('reports a fresh profile absent after three bounded presence checks', () => { const fileExists = vi.fn(() => false) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false) - expect(fileExists).toHaveBeenCalledTimes(2) + expect(fileExists).toHaveBeenCalledTimes(3) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index bb82e070e125..dd5e4b4302f3 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -12,7 +12,6 @@ import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' -import { join } from 'node:path' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { DISPATCH_DOUBT_PROVIDER_IDLE } from '../native-chat/agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' @@ -42,10 +41,9 @@ import { import { AgentSessionRecordStore } from './agent-session-record-store' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' -import { - AGENT_SESSION_STORE_DIR_NAME, - agentSessionStorePath -} from './agent-session-record-store-file' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -61,11 +59,25 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' +/** Whether this profile holds a structured chat: a record or tab in the journal database, or the + * records file a profile from before it carries while the database still owes its copy. */ export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { - const filePath = agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME)) + const databasePath = journalDatabasePath(stateDirectory) + if (fileExists(databasePath)) { + try { + const holds = journalDatabaseHoldsAgentSessions(databasePath) + if (holds !== undefined) { + return holds + } + } catch { + // A database that cannot be read cannot say it is empty. + return true + } + } + const filePath = legacyAgentSessionStorePath(stateDirectory) return fileExists(filePath) || fileExists(`${filePath}.bak`) } @@ -194,7 +206,14 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + deps.onError + ? deps.onError({ scope: 'structured-agent-session-record-import', error: report }) + : console.warn('[structured-agent-session] importing the chat records file', report) + }) try { return await installOnJournal(deps, journalDatabase) } catch (error) { @@ -210,10 +229,7 @@ async function installOnJournal( ): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers - const store = await AgentSessionRecordStore.open({ - directory: join(deps.stateDirectory, AGENT_SESSION_STORE_DIR_NAME), - hostId: deps.hostId - }) + const store = AgentSessionRecordStore.open({ journalDatabase, hostId: deps.hostId }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 3cf61c0bf3c9..d86c54282c10 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -142,6 +142,8 @@ describe('a process that inherited a spawn token', () => { resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), resolveEnvironment: async () => ({}) }) + // The installed host reads the record the seed wrote, so the check below has a lease to act on. + expect(host.deps.store.getRecord(SESSION)?.lease.claimStatus).toBe('released') await host.reconcileRestartLeases() // Install work that is not awaited would have run by now; nothing here waits on a timer. await new Promise((resolve) => setTimeout(resolve, 50)) diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 33b26f7569bb..67a7d6b080de 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -1,23 +1,42 @@ -// A profile whose chat records a newer Orca wrote opens read-only. The startup reconcile cannot -// write there, which is bookkeeping: startup must still finish rather than put the whole app into -// its degraded "Session restore failed" mode, and the file must be left exactly as it was. +// A profile whose chat records a newer Orca wrote opens read-only. The startup reconcile adjudicates +// its leases in memory and writes nothing there; a reconcile whose write fails is bookkeeping too. +// Either way startup must finish rather than put the whole app into its degraded "Session restore +// failed" mode. -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { readFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type * as AgentSessionRecordRows from './agent-session-record-rows' import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath -} from './agent-session-record-store-file' + seedTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild +} from './agent-session-record-store-test-harness' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { OrcaRuntimeService } from './orca-runtime' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +const writes = vi.hoisted(() => ({ failing: false })) + +vi.mock('./agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing) { + throw new Error('disk I/O error') + } + return actual.writeAgentSessionStoreRows(...args) + } + } +}) + let root: string beforeEach(async () => { @@ -25,29 +44,22 @@ beforeEach(async () => { }) afterEach(async () => { + writes.failing = false await stopStructuredAgentSessionRuntime().catch(() => undefined) + closeTestJournalHostDatabases() vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) -/** A profile whose record store a newer Orca wrote, with one chat in it. */ -async function seedNewerStore() { - const storeDirectory = join(root, 'agent-sessions') - const path = agentSessionStorePath(storeDirectory) +/** A profile with one chat whose last owner is gone; `newer` stamps it as a newer Orca's. */ +async function seedChat(options: { newer?: boolean } = {}) { const record = agentSessionRecordFixture() - await mkdir(storeDirectory, { recursive: true }) - await writeFile( - path, - JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + 1, - hostId: 'local', - records: { [record.sessionId]: record }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - ) - return { storeDirectory, path, sessionId: record.sessionId } + await seedTestAgentSessionRecordStore(root, { records: [record] }) + if (options.newer) { + await seedTestAgentSessionStoreFromNewerBuild(root) + } + closeTestJournalHostDatabases() + return { path: journalDatabasePath(root), sessionId: record.sessionId } } function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { @@ -73,31 +85,45 @@ function startupRuntime(onError?: (input: { scope: string; error: unknown }) => return runtime } -it('finishes startup over records a newer Orca wrote, reports it, and writes nothing', async () => { - const { storeDirectory, path, sessionId } = await seedNewerStore() +it('finishes startup over records a newer Orca wrote, reads them, and writes nothing', async () => { + const { path, sessionId } = await seedChat({ newer: true }) const bytes = await readFile(path) - const files = await readdir(storeDirectory) const onError = vi.fn() const runtime = startupRuntime(onError) // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + expect(onError).not.toHaveBeenCalled() + const host = getStructuredAgentSessionHost() + expect(host?.sessionAgent(sessionId)).toBe('claude') + // Adjudicated in memory only: the verdict is re-derived at the next start. + expect(host?.deps.store.getRecord(sessionId)?.lease.unreconciled).toBe(false) + await stopStructuredAgentSessionRuntime() + expect(await readFile(path)).toEqual(bytes) +}) + +it('finishes startup when the reconcile cannot write, and reports it', async () => { + const { sessionId } = await seedChat() + const onError = vi.fn() + const runtime = startupRuntime(onError) + writes.failing = true + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + expect(onError).toHaveBeenCalledOnce() expect(onError).toHaveBeenCalledWith({ scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining({ message: 'agent_session_legacy_required' }) + error: expect.objectContaining({ message: 'disk I/O error' }) }) expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') - await stopStructuredAgentSessionRuntime() - expect(await readFile(path)).toEqual(bytes) - expect(await readdir(storeDirectory)).toEqual(files) }) // The desktop installs its host with no error sink, so the failure is logged rather than dropped. it('logs the failure when the host has no error sink', async () => { - await seedNewerStore() + await seedChat() const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + writes.failing = true await expect( startupRuntime().prepareStructuredAgentSessionStartupRestoration() @@ -105,6 +131,31 @@ it('logs the failure when the host has no error sink', async () => { expect(warn).toHaveBeenCalledWith( '[structured-agent-session] reconciling chat leases failed', - expect.objectContaining({ message: 'agent_session_legacy_required' }) + expect.objectContaining({ message: 'disk I/O error' }) + ) +}) + +// Closing a chat tab drops it from the restore index, which a newer Orca's records refuse: the +// close is reported and goes on, since bookkeeping never keeps a tab open. +it('closes a chat tab over records a newer Orca wrote, reporting the index it cannot write', async () => { + const { sessionId } = await seedChat({ newer: true }) + const runtime = startupRuntime(vi.fn()) + await runtime.prepareStructuredAgentSessionStartupRestoration() + const host = getStructuredAgentSessionHost() + const close = vi.spyOn(host!, 'close') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected close path, reached with the one field it reads. + const internal = runtime as unknown as { + closeStructuredAgentSessionTab(tab: { sessionId: string }): Promise + } + + await expect(internal.closeStructuredAgentSessionTab({ sessionId })).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording a closed chat tab failed', + expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'journalWrittenByNewerOrca' } }) + }) ) + expect(close).toHaveBeenCalledWith(sessionId) }) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 61fa020e4276..84c14b2e23cd 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -1,11 +1,11 @@ // With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), // which reads every chat whose tab was open at quit. That read must not wait on record-store // bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs -// a bounded number of lock waits, not one per chat. +// a bounded number of failed writes, not one per chat. -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' @@ -13,40 +13,43 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import type * as FileTransactionLock from '../file-transaction-lock' -import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AgentSessionRecordStore } from './agent-session-record-store' +import type * as AgentSessionRecordRows from './agent-session-record-rows' import { AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath + legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { + readPersistedTestAgentSessionStore, + seedTestAgentSessionStoreFromNewerBuild +} from './agent-session-record-store-test-harness' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { OrcaRuntimeService } from './orca-runtime' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -// `failing` refuses every take; `grants` lets that many more through, then refuses. -const lock = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) +// `failing` fails every record write; `grants` lets that many more through, then fails. +const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) -vi.mock('../file-transaction-lock', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('./agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - withFileTransactionLock: (...args: Parameters) => { - if (lock.failing || lock.grants <= 0) { - lock.refused += 1 - // What proper-lockfile throws once its retries give up, about 3 s later. - return Promise.reject( - Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) - ) + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing || writes.grants <= 0) { + writes.refused += 1 + throw new Error('disk I/O error') } - lock.grants -= 1 - return actual.withFileTransactionLock(...args) + writes.grants -= 1 + return actual.writeAgentSessionStoreRows(...args) } } }) @@ -55,6 +58,7 @@ const PROMPT = 'add a retry' const CHAT_A = 'chat-a-0001' const CHAT_B = 'chat-b-0002' const CLEARED = 'chat-s-0003' +const OWED = 'chat-o-0004' let root: string @@ -63,8 +67,9 @@ beforeEach(async () => { }) afterEach(async () => { - Object.assign(lock, { failing: false, grants: Infinity, refused: 0 }) + Object.assign(writes, { failing: false, grants: Infinity, refused: 0 }) await stopStructuredAgentSessionRuntime().catch(() => undefined) + closeTestJournalHostDatabases() vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) @@ -107,18 +112,19 @@ function chatRecord( return { ...record, ...codex, ...clear } } -/** Writes the record store; `visible` is the saved tab index, absent on a legacy profile. */ -async function seedStore( +/** The records file a profile from before the chat database carries, which the install imports; + * `visible` is its saved tab index, absent on a legacy profile. `newer` instead leaves the records + * in a database a newer Orca wrote. Then each chat's history as the last run left it: one prompt, + * accepted, so nothing is left to send. */ +async function seedProfile( records: AgentSessionRecord[], - options: { newer?: boolean; visible?: string[] } = {} + options: { newer?: boolean; visible?: string[]; history?: AgentSessionRecord[] } = {} ) { - const storeDirectory = join(root, 'agent-sessions') - const path = agentSessionStorePath(storeDirectory) - await mkdir(storeDirectory, { recursive: true }) + await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) await writeFile( - path, + legacyAgentSessionStorePath(root), JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + (options.newer ? 1 : 0), + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: 'local', records: Object.fromEntries(records.map((record) => [record.sessionId, record])), operations: {}, @@ -127,13 +133,12 @@ async function seedStore( ...(options.visible ? { visibleSessionIds: options.visible } : {}) }) ) - return { storeDirectory, path } -} - -/** Each chat's history as the last run left it: one prompt, accepted, so nothing is left to send. */ -async function seedHistory(records: AgentSessionRecord[]): Promise { - const database = JournalHostDatabase.open(root) - for (const record of records) { + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + hostId: 'local', + onLegacyRecordImportReport: () => undefined + }) + for (const record of options.history ?? records) { const fence = record.lease.runtimeFence const journal = await openAgentSessionJournal({ identity: journalIdentityFor( @@ -158,6 +163,30 @@ async function seedHistory(records: AgentSessionRecord[]): Promise { await journal.close() } database.close() + if (options.newer) { + await seedTestAgentSessionStoreFromNewerBuild(root) + } + return { path: journalDatabasePath(root) } +} + +/** A chat opened, with its tab, while the records file could not be read and the copy was owed. */ +async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string): Promise { + // A directory where the file belongs: the read fails in a way that can clear. + await mkdir(legacyAgentSessionStorePath(root), { recursive: true }) + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + hostId: 'local', + onLegacyRecordImportReport: () => undefined + }) + database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run(record.sessionId, JSON.stringify(record)) + await AgentSessionRecordStore.open({ + journalDatabase: database, + hostId: 'local' + }).setSessionTabVisibility(record.sessionId, true, tabId) + database.close() + await rm(legacyAgentSessionStorePath(root), { recursive: true }) } function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { @@ -229,83 +258,86 @@ function spyOnTabWrites() { } } -async function tabIndexOnDisk(path: string): Promise { - return JSON.parse(await readFile(path, 'utf-8')).visibleSessionIds +async function tabIndexOnDisk(): Promise { + return (await readPersistedTestAgentSessionStore(root)).sessionTabs?.map((tab) => tab.sessionId) } describe('restoring the chat tabs open at quit', () => { it.each([ - { store: 'records a newer Orca wrote', newer: true, lockFails: false }, - { store: 'a lock that keeps failing', newer: false, lockFails: true } - ])('lists and reads every chat from $store, and writes nothing', async ({ newer, lockFails }) => { - const records = [ - chatRecord(CHAT_A), - chatRecord(CHAT_B), - chatRecord(CLEARED, { clearedInto: CHAT_A }) - ] - const { storeDirectory, path } = await seedStore(records, { newer, visible: [CHAT_A, CHAT_B] }) - await seedHistory(records.slice(0, 2)) - const bytes = await readFile(path) - const files = await readdir(storeDirectory) - const writes = spyOnTabWrites() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - // Free when the store opens, then held for good. - const { runtime, onError, published } = startupRuntime({ - afterInstall: () => { - lock.failing = lockFails - } - }) - - await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() - - expect(published().map((tab) => tab.id)).toEqual([ - `agent-session:${CHAT_A}`, - `agent-session:${CHAT_B}` - ]) - expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) - await expectHistory(CHAT_A) - await expectHistory(CHAT_B) - expect(onError).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining( - newer ? { message: 'agent_session_legacy_required' } : { code: 'ELOCKED' } - ) - }) - expect(writes.visibility).not.toHaveBeenCalled() - expect(writes.seed).not.toHaveBeenCalled() - await stopStructuredAgentSessionRuntime() - if (newer) { - expect(await readFile(path)).toEqual(bytes) - expect(await readdir(storeDirectory)).toEqual(files) - } - }) - - // Each refused take stands for one lock wait of about 3 s. - it.each([1, 4, 8])( - 'waits on a held lock once per startup step with %i chats open', - async (count) => { - const chats = Array.from({ length: count }, (_, index) => `chat-${index}-000${index}`) - const records = chats.map((sessionId) => chatRecord(sessionId)) - await seedStore(records, { visible: chats }) - await seedHistory(records) + { store: 'records a newer Orca wrote', newer: true, writesFail: false }, + { store: 'a store whose writes keep failing', newer: false, writesFail: true } + ])( + 'lists and reads every chat from $store, and writes nothing', + async ({ newer, writesFail }) => { + const records = [ + chatRecord(CHAT_A), + chatRecord(CHAT_B), + chatRecord(CLEARED, { clearedInto: CHAT_A }) + ] + const { path } = await seedProfile(records, { + newer, + visible: [CHAT_A, CHAT_B], + history: records.slice(0, 2) + }) + const bytes = await readFile(path) + const tabWrites = spyOnTabWrites() vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, published } = startupRuntime({ + // Writable when the store opens, then failing for good. + const { runtime, onError, published } = startupRuntime({ afterInstall: () => { - lock.failing = true + writes.failing = writesFail } }) - await runtime.prepareStructuredAgentSessionStartupRestoration() - const prepared = lock.refused - await runtime.restoreStructuredAgentSessionTabs() + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() - expect(published()).toHaveLength(count) - expect(prepared).toBe(1) - expect(lock.refused - prepared).toBe(1) + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${CHAT_A}`, + `agent-session:${CHAT_B}` + ]) + expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) + await expectHistory(CHAT_A) + await expectHistory(CHAT_B) + // A newer Orca's leases are adjudicated in memory, so nothing fails there. + if (newer) { + expect(onError).not.toHaveBeenCalled() + } else { + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-lease-reconcile', + error: expect.objectContaining({ message: 'disk I/O error' }) + }) + } + expect(tabWrites.visibility).not.toHaveBeenCalled() + expect(tabWrites.seed).not.toHaveBeenCalled() + await stopStructuredAgentSessionRuntime() + if (newer) { + expect(await readFile(path)).toEqual(bytes) + } } ) + // Each failed write stands for one refused commit. + it.each([1, 4, 8])('fails one write per startup step with %i chats open', async (count) => { + const chats = Array.from({ length: count }, (_, index) => `chat-${index}-000${index}`) + const records = chats.map((sessionId) => chatRecord(sessionId)) + await seedProfile(records, { visible: chats }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + afterInstall: () => { + writes.failing = true + } + }) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + const prepared = writes.refused + await runtime.restoreStructuredAgentSessionTabs() + + expect(published()).toHaveLength(count) + expect(prepared).toBe(1) + expect(writes.refused - prepared).toBe(1) + }) + describe('on a legacy profile, with no tab index yet', () => { const legacyChats = () => [ chatRecord(CHAT_A, { codex: true }), @@ -314,13 +346,12 @@ describe('restoring the chat tabs open at quit', () => { it('records every restored chat in one write', async () => { const records = legacyChats() - const { path } = await seedStore(records) - await seedHistory(records) - const writes = spyOnTabWrites() + await seedProfile(records) + const tabWrites = spyOnTabWrites() const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) await runtime.prepareStructuredAgentSessionStartupRestoration() - // One more take, then held: a seed written chat by chat would stop part way. - lock.grants = 1 + // One more write, then failing: a seed written chat by chat would stop part way. + writes.grants = 1 await runtime.restoreStructuredAgentSessionTabs() @@ -328,41 +359,59 @@ describe('restoring the chat tabs open at quit', () => { `agent-session:${CHAT_A}`, `agent-session:${CHAT_B}` ]) - expect(await tabIndexOnDisk(path)).toEqual([CHAT_A, CHAT_B]) - expect(writes.seed).toHaveBeenCalledOnce() - expect(writes.visibility).not.toHaveBeenCalled() + expect(await tabIndexOnDisk()).toEqual([CHAT_A, CHAT_B]) + expect(tabWrites.seed).toHaveBeenCalledOnce() + expect(tabWrites.visibility).not.toHaveBeenCalled() }) - it('waits on a held lock once more, for that one write', async () => { + it('fails one write more, for that one write', async () => { const records = legacyChats() - await seedStore(records) - await seedHistory(records) + await seedProfile(records) vi.spyOn(console, 'warn').mockImplementation(() => undefined) const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B], afterInstall: () => { - lock.failing = true + writes.failing = true } }) await runtime.prepareStructuredAgentSessionStartupRestoration() - const prepared = lock.refused + const prepared = writes.refused await runtime.restoreStructuredAgentSessionTabs() expect(published()).toHaveLength(2) expect(prepared).toBe(1) // The restore's lease check and the seed. - expect(lock.refused - prepared).toBe(2) + expect(writes.refused - prepared).toBe(2) + }) + + // The profile never lists a Claude chat, so only the tab row that chat left brings it back. + it("restores a chat opened while the copy was owed beside the profile's chats", async () => { + const owed = chatRecord(OWED) + await seedChatOpenedWhileOwed(owed, 'tab-opened-while-owed') + const records = [chatRecord(CHAT_A, { codex: true })] + await seedProfile(records, { history: [owed, ...records] }) + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A] }) + + await runtime.restoreStructuredAgentSessionTabs() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${OWED}`, + `agent-session:${CHAT_A}` + ]) + expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ + { tabId: 'tab-opened-while-owed', sessionId: OWED }, + { tabId: `structured-agent-session-${CHAT_A}`, sessionId: CHAT_A } + ]) }) it('still lists the chats when that write fails, and leaves the index absent', async () => { const records = legacyChats() - const { path } = await seedStore(records) - await seedHistory(records) + await seedProfile(records) const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) await runtime.prepareStructuredAgentSessionStartupRestoration() - lock.failing = true + writes.failing = true await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() @@ -370,9 +419,12 @@ describe('restoring the chat tabs open at quit', () => { await expectHistory(CHAT_A) expect(warn).toHaveBeenCalledWith( '[structured-agent-session] recording restored chat tabs failed', - { sessionIds: [CHAT_A, CHAT_B], error: expect.objectContaining({ code: 'ELOCKED' }) } + { + sessionIds: [CHAT_A, CHAT_B], + error: expect.objectContaining({ message: 'disk I/O error' }) + } ) - expect(await tabIndexOnDisk(path)).toBeUndefined() + expect(await tabIndexOnDisk()).toBeUndefined() }) }) }) diff --git a/src/main/runtime/structured-agent-session-tab-index-seed.ts b/src/main/runtime/structured-agent-session-tab-index-seed.ts index 181d67128a1f..188a419f31e2 100644 --- a/src/main/runtime/structured-agent-session-tab-index-seed.ts +++ b/src/main/runtime/structured-agent-session-tab-index-seed.ts @@ -18,7 +18,8 @@ export async function seedStructuredAgentSessionTabIndex( if (!host?.getPersistedVisibleSessionTabIndex || !host.showSessionTabs) { return } - const listed = new Set(host.getPersistedVisibleSessionTabIndex().sessionIds) + const index = host.getPersistedVisibleSessionTabIndex() + const listed = new Set(index.present ? index.sessionIds : []) const opened = new Set(restored) // In the restore's own order, so the seeded tabs keep the order the profile gave them. const unlisted = [...new Set([...targets, ...restored])].filter( diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts index 2c05aa5004db..62e6da1d396b 100644 --- a/src/shared/agent-session-next-fence.ts +++ b/src/shared/agent-session-next-fence.ts @@ -1,9 +1,10 @@ // The only place a new fence number is chosen. // -// Normally that is just "one past the current fence". After the record store falls back to its -// backup it is not: the commit that never landed may already have granted a fence the backup cannot -// show, and `isAgentSessionFenceCurrent` compares with STRICT EQUALITY, so minting that exact -// number would hand a second writer a lease the first one still believes it holds. +// Normally that is just "one past the current fence". For a record the one-time import took from +// the records file's backup, or found beside a set-aside copy, it is not: the commit that never +// landed may already have granted a fence the copy cannot show, and `isAgentSessionFenceCurrent` +// compares with STRICT EQUALITY, so minting that exact number would hand a second writer a lease +// the first one still believes it holds. // // Recovery records the floor instead of rewriting the current fence, because `live` means a handle // proven at exactly the current fence — moving it would invalidate the very records recovery exists @@ -12,6 +13,8 @@ import type { AgentSessionLease } from './agent-session-record' -export function nextAgentSessionFence(lease: AgentSessionLease): number { +export function nextAgentSessionFence( + lease: Pick +): number { return Math.max(lease.runtimeFence + 1, lease.minimumNextFence ?? 0) } diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 90caec22bb79..136b86af2b77 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -37,9 +37,8 @@ export type AgentSessionOperationOutcome = /** * Empty exactly when `launch` recorded a terminal surface — a PTY has a handle, not a session * id. Kept a required string rather than made optional because a build that predates `launch` - * rejects a `succeeded` row without one, and a single rejected row invalidates the whole - * store on load (`agent-session-record-store-file.ts`). A downgrade must skip what it cannot - * read, not lose every lease in the file. + * rejects a `succeeded` row without one: the records file such a build keeps is unusable to it + * whole, and a row the database holds is dropped from the ledger, so its retry runs again. */ sessionId: string conversationCommand?: AgentSessionConversationCommandResult @@ -50,12 +49,10 @@ export type AgentSessionOperationOutcome = * settings move: a replay must return what ran, not what would run now. * * Typed `unknown`, and deliberately NOT checked by `isAgentSessionOperationRow`, for the same - * reason `sessionId` above stays required: a row this file rejects makes the whole store - * unparseable, and a primary and backup that both fail to parse raise - * `agent_session_store_corrupt` rather than degrading. `isAgentLaunchResult` is a - * hand-maintained mirror of a result type later work will edit, so a field tightened there - * would reject rows this same build wrote and take every lease in the file with them. It is - * narrowed where the value is read instead, where a payload we cannot read costs one replay. + * reason `sessionId` above stays required: a load drops a row it rejects. `isAgentLaunchResult` + * is a hand-maintained mirror of a result type later work will edit, so a field tightened + * there would reject rows this same build wrote and lose their replay. It is narrowed where + * the value is read instead, where a payload we cannot read costs one replay. */ launch?: unknown } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 12a26e64ac77..b8280cf332da 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -120,8 +120,8 @@ export type AgentSessionLease = { /** True from load until the host adjudicates it; no writer is granted while set. */ unreconciled: boolean /** - * Lowest fence a future grant may use. Set only after the store recovers from its backup, where - * the commit that never landed may already have granted a fence the backup cannot show. The + * Lowest fence a future grant may use. Set only when the records file's copy came from its backup, + * or sat beside a set-aside copy of the same chat: either may hide a fence already granted. The * CURRENT fence is deliberately left alone: `live` means a handle proven at exactly that number, * so rewriting it would invalidate the record it is trying to save. */ diff --git a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts deleted file mode 100644 index 96780a185176..000000000000 --- a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, test } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../src/shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' -import { agentSessionStorePath } from '../../../src/main/runtime/agent-session-record-store-file' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -// The last release before a proof of death named its owner and its last proof of life. -const BASELINE_REF = 'v1.4.211' -const SESSION = 'session-alpha-1' - -test('an older build loads a proof of death that names its owner and its last proof of life', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-death-evidence-downgrade-')) - try { - // A live owner at fence 7, last renewed at 30000, found gone when this build relaunches. - writeFileSync( - agentSessionStorePath(directory), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: agentSessionRecordFixture(agentSessionLeaseFixture()) }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - ) - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) - const evidence = store.getRecord(SESSION)?.lease.deathEvidence - expect(evidence).toEqual({ - kind: 'pid-absent', - detail: 'recorded pid absent on host', - observedAt: 90_000, - ownerFence: 7, - lastProvenAliveAt: 30_000 - }) - - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const baseline = await importReleaseCheckoutModule( - checkout, - 'src/main/runtime/agent-session-record-store.ts' - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read members called below; a missing one fails the test. - const OldStore = baseline.AgentSessionRecordStore as { - open: (args: { directory: string; hostId: string }) => Promise - } - const old = await OldStore.open({ directory, hostId: 'local' }) - expect(old.isSessionUnreadable(SESSION)).toBe(false) - expect(old.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: evidence - }) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -}) diff --git a/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts deleted file mode 100644 index 80626f79ae31..000000000000 --- a/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, test } from 'vitest' -import { resolveStructuredSessionRecovery } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution' -import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -// The last release before recovery released an owner it could not prove gone. -const BASELINE_REF = 'v1.4.211' -const SESSION = 'session-unproven' -const NOW = 1_800_000_000_000 -const LOCATION = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' as const -} - -function reserveRequest(expectedFence: number | null, spawnToken: string, operation: number) { - return { - sessionId: SESSION, - location: LOCATION, - provider: 'codex' as const, - accountHome: { variable: 'CODEX_HOME' as const, path: '/tmp/codex' }, - // Older builds still read the owner kind from the request. - runtimeKind: 'native' as const, - expectedFence, - spawnToken, - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' as const }, - operation: { - callerKey: 'test', - operationId: `${NOW}-${String(operation).padStart(32, '0')}`, - fingerprint: 'create' - }, - now: NOW - } -} - -test('an older build loads, and starts over, a lease released with no death evidence', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-unproven-release-downgrade-')) - try { - // This build: an owner whose identity can never be verified is released, with no evidence. - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - const reserved = await store.reserveOwner(reserveRequest(null, 'spawn-a', 1)) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, - now: NOW - }) - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { ...record.lease, handoffStage: 'recovering' } - })) - await expect( - resolveStructuredSessionRecovery( - { - store, - probeRecord: async () => ({ outcome: 'indeterminate', reason: 'no start time' }), - now: () => NOW - }, - SESSION - ) - ).resolves.toBe('resolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - deathEvidence: null - }) - - // The older build reads exactly what this one wrote. - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const baseline = await importReleaseCheckoutModule( - checkout, - 'src/main/runtime/agent-session-record-store.ts' - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read/reconcile/reserve members called below; a missing one fails the test. - const OldStore = baseline.AgentSessionRecordStore as { - open: (args: { directory: string; hostId: string }) => Promise - } - const old = await OldStore.open({ directory, hostId: 'local' }) - expect(old.isSessionUnreadable(SESSION)).toBe(false) - expect(old.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - deathEvidence: null - }) - await old.reconcileOnRestart({ - probe: async () => ({ outcome: 'indeterminate', reason: 'no owner to probe' }), - now: NOW + 1 - }) - const released = old.getRecord(SESSION)?.lease - expect(released).toMatchObject({ claimStatus: 'released', handoffStage: null }) - // And it can start a new owner over it. - const restarted = await old.reserveOwner( - reserveRequest(released?.runtimeFence ?? null, 'spawn-b', 2) - ) - expect(restarted.record.lease).toMatchObject({ claimStatus: 'reserved' }) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -})