diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 3da93b4f5f9..69df486ddc8 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -76,7 +76,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-proof-tool-ends.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts', @@ -105,10 +104,10 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-repeated-stop.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-newer-orca.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts', - 'src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind-live-send.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts', @@ -181,7 +180,6 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/runtime/structured-agent-session-integration-replay.test.ts', 'src/main/runtime/structured-agent-session-integration.test.ts', 'src/main/runtime/structured-agent-session-mobile-restore.test.ts', - 'src/main/runtime/structured-agent-session-record-import-install.test.ts', 'src/main/runtime/structured-agent-session-rollback-compatibility.test.ts', 'src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts', 'src/main/runtime/structured-agent-session-startup-reconcile.test.ts', 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 deleted file mode 100644 index 120588b306d..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts +++ /dev/null @@ -1,227 +0,0 @@ -// 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 00538fe75f9..28546de389a 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 @@ -2,12 +2,10 @@ // // `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`. // `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so -// an append is one INSERT. `journal_repairs` is only for an older build, which reads and writes it -// on this same schema version; this build does neither. `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. 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). +// an append is one INSERT. `journal_repairs`, `journal_imports` and `journal_set_aside` are only +// for an older build, which reads and writes them on this same schema version; this build does +// neither. 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 is opened read-only here @@ -51,8 +49,8 @@ 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. */ +/** Version 4: the chat records. Each row is one JSON value, so a row this build cannot read stays + * byte-identical. */ export function createAgentSessionRecordTablesSql(): string { return ` CREATE TABLE IF NOT EXISTS agent_session_records ( 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 649cfccdf1a..473504f51ec 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,15 +8,17 @@ 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' +import { + createJournalTablesSql, + JOURNAL_DB_OLDEST_RELEASED_VERSION, + JOURNAL_DB_SCHEMA_VERSION +} from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { deleteJournalEpochRows, - deleteUnpublishedJournalRows, insertJournalRow, iterateJournalEpochRows, publishJournalSessionEpoch, @@ -66,7 +68,7 @@ afterEach(async () => { describe('the host journal database open', () => { it('creates every table and reads back every load-bearing pragma', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") @@ -97,14 +99,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, NO_LEGACY_JOURNAL_RECORDS).db + const seeded = openJournalDatabase(dbPath).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, NO_LEGACY_JOURNAL_RECORDS) + const opened = openJournalDatabase(dbPath) try { expect(opened.readOnly).toBe(true) expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1') @@ -121,7 +123,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, NO_LEGACY_JOURNAL_RECORDS)).toThrow() + expect(() => openJournalDatabase(failing)).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 +132,7 @@ describe('the host journal database open', () => { describe('journal row statements', () => { it('serves replay, resume and an epoch discard', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { @@ -157,27 +159,8 @@ describe('journal row statements', () => { } }) - it('deletes only the rows no pointer names', () => { - 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')) - insertJournalRow(db, 'session-2', epochRow(1, 'epoch-stale')) - publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-live') - - deleteUnpublishedJournalRows(db, 'session-1') - deleteUnpublishedJournalRows(db, 'session-2') - - expect(rowsOf(db, 'session-1', 'epoch-copying')).toEqual([]) - expect(rowsOf(db, 'session-2', 'epoch-live')).toEqual([1]) - expect(rowsOf(db, 'session-2', 'epoch-stale')).toEqual([]) - } finally { - db.close() - } - }) - it('refuses a duplicate sequence inside one epoch of one chat', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { insertJournalRow(db, 'session-1', epochRow(1)) expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() @@ -189,7 +172,7 @@ describe('journal row statements', () => { }) it('moves the epoch pointer in place', () => { - const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(dbPath).db try { publishJournalSessionEpoch(db, SESSION, 'epoch-1') publishJournalSessionEpoch(db, SESSION, 'epoch-2') @@ -217,9 +200,7 @@ describe('schema creation', () => { return original.call(this, sql, options) }) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - 'crash before the version is published' - ) + expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published') pragma.mockRestore() const inspected = new Database(dbPath) @@ -235,6 +216,25 @@ describe('schema creation', () => { } }) + it('gives a version 3 database the chat record tables, keeping its rows', () => { + const released = new Database(dbPath) + released.exec(createJournalTablesSql()) + released.exec("INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1')") + released.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) + released.close() + + const { db } = openJournalDatabase(dbPath) + try { + expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) + expect(db.prepare('SELECT session_id FROM journal_sessions').all()).toEqual([ + { session_id: 's1' } + ]) + expect(db.prepare('SELECT count(*) AS n FROM agent_session_records').get()).toEqual({ n: 0 }) + } finally { + db.close() + } + }) + // Versions 1 and 2 were written only by unreleased builds; neither is migrated. it.each([1, 2])('refuses a version %i database without touching the file', async (version) => { const earlier = new Database(dbPath) @@ -249,12 +249,8 @@ INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) earlier.close() const before = await digest(dbPath) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - `unreleased schema ${version}` - ) - expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( - JournalUnreleasedSchemaError - ) + expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) + expect(() => openJournalDatabase(dbPath)).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 a306ad6e564..04483d09958 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -4,7 +4,6 @@ // 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 { @@ -19,62 +18,21 @@ import { ensureQueuedMessagesTable } from './queued-message-schema' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 /** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ export const JOURNAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024 -/** Every commit but a first-use copy's batches, which no reader follows until a synced commit. */ -export const JOURNAL_SYNCHRONOUS = 'FULL' +/** Every commit is fsynced before its caller continues. */ +const JOURNAL_SYNCHRONOUS = 'FULL' 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) } -/** 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 { +/** Whether the database holds any chat record or tab, read-only. */ +export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean { const db = new Database(dbPath, { readonly: true, fileMustExist: true }) try { const tables = new Set( @@ -83,23 +41,16 @@ export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | und .all() .map(({ name }) => name) ) - const holds = ['agent_session_records', 'agent_session_tabs'].some( + return ['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 { +export function openJournalDatabase(dbPath: string): OpenJournalDatabase { const probe = new Database(dbPath) let stored: number try { @@ -112,8 +63,7 @@ export function openJournalDatabase( probe.close() return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), - readOnly: true, - legacyRecordImportOwed: false + readOnly: true } } let transferred = false @@ -125,14 +75,14 @@ export function openJournalDatabase( ) } configureJournalPragmas(probe, stored) - const legacyRecordImportOwed = migrateJournalSchema(probe, stored, legacyRecords) + migrateJournalSchema(probe, stored) // 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, legacyRecordImportOwed } + return { db: probe, readOnly: false } } finally { if (!transferred) { probe.close() @@ -159,35 +109,20 @@ function configureJournalPragmas(db: Database.Database, stored: number): void { } /** - * 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. + * 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. */ -function migrateJournalSchema( - db: Database.Database, - stored: number, - legacyRecords: JournalLegacyRecordImport -): boolean { +function migrateJournalSchema(db: Database.Database, stored: number): void { if (stored >= JOURNAL_DB_SCHEMA_VERSION) { - return false + return } runJournalTransaction(db, () => { 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-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts deleted file mode 100644 index eed26b20388..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// An empty chat beside a pre-SQLite journal explains itself. -// -// The SQLite move shipped no importer, so a session whose history is a -// `log.jsonl` founds a fresh empty journal beside it and looks exactly like a -// chat created seconds ago. One status row is the difference. - -import { mkdir, mkdtemp, 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 { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' -import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant' -import { journalDirectoryFor } from './journal-paths' -import type { AgentSessionJournal } from './journal-store' -import type { openAgentSessionJournal } from './journal-store-factory' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - loadTestJournal, - deleteTestJournalRow, - insertTestJournalRowJson, - liveTestJournalRows, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -const DISCLOSURE_ITEM_ID = agentJournalItemKey(JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY) - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => (clock += 1), - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -/** Where this chat's history lived before the journal was one database per host. */ -function legacyDir(): string { - return journalDirectoryFor(root, IDENTITY) -} - -async function writeRemnant(name = 'log.jsonl'): Promise { - await mkdir(legacyDir(), { recursive: true }) - await writeFile(join(legacyDir(), name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') -} - -function disclosure(journal: AgentSessionJournal): string | null { - const row = journal.snapshot().items.find((entry) => entry.itemId === DISCLOSURE_ITEM_ID) - return row?.body.kind === 'status' ? row.body.text : null -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-remnant-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('a chat whose history is still in the pre-SQLite format', () => { - it('says how to carry on, and where the transcript is', async () => { - await writeRemnant() - - const journal = await open() - - expect(disclosure(journal)).toContain('send a message to pick up where you left off') - expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) - expect(disclosure(journal)).toContain('Codex') - }) - - // Both files is the normal shape of a pre-SQLite directory: every epoch roll - // staged a snapshot whether or not anything compacted into it, so preferring - // the snapshot would name an empty file for ~every affected chat. - it('names the log, not the snapshot staged beside it', async () => { - await writeRemnant('log.jsonl') - await writeRemnant('snapshot.json') - - const journal = await open() - - expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) - expect(disclosure(journal)).not.toContain('snapshot.json') - }) - - it('falls back to the snapshot when a chat has no log beside it', async () => { - await writeRemnant('snapshot.json') - - const journal = await open() - - expect(disclosure(journal)).toContain(join(legacyDir(), 'snapshot.json')) - }) - - it('says nothing to a chat that is genuinely new', async () => { - const journal = await open() - - expect(journal.snapshot().items).toEqual([]) - }) - - // Counting rows proves nothing here — the append upserts by identity, so a - // second append would still leave exactly one. The revision is what moves. - it('does not re-append the row on a later open', async () => { - await writeRemnant() - const first = await open() - const firstRevision = first - .snapshot() - .items.find((e) => e.itemId === DISCLOSURE_ITEM_ID)?.revision - await first.close() - - const reopened = await open() - - const row = reopened.snapshot().items.find((e) => e.itemId === DISCLOSURE_ITEM_ID) - expect(firstRevision).toBe(1) - expect(row?.revision).toBe(1) - expect(reopened.cursor().sequence).toBe(2) - }) - - // The epoch commit and this append are separate transactions; if the append is - // lost the epoch exists but holds nothing, and every later open takes the - // adopt branch. The offer has to survive that. - it('offers the message again when a committed epoch holds nothing', async () => { - const founded = await open() - await founded.close() - await writeRemnant() - - const reopened = await open() - - expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl')) - }) - - // A damaged journal fails its open before this branch, so nothing is appended to it. - it('writes nothing into a damaged journal', async () => { - const journal = await open() - await journal.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'history' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const opened = openTestJournalHostDatabase(root) - try { - deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1) - } finally { - opened.close() - } - await writeRemnant() - - await expect(open()).rejects.toMatchObject({ - refusal: { details: { reason: 'journalCorrupt' } } - }) - expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ - damage: { sequence: 1, cause: 'no-epoch-row' } - }) - const stored = openTestJournalHostDatabase(root) - try { - expect(liveTestJournalRows(stored.db, IDENTITY.sessionId).map((row) => row.seq)).toEqual([2]) - } finally { - stored.close() - } - }) - - // A newer row fails the load before the empty-epoch branch would write a notice. - it("writes nothing into a newer Orca's journal, whose open is refused", async () => { - const founded = await open() - const epoch = founded.epoch - await founded.close() - insertTestJournalRowJson( - openTestJournalHostDatabase(root).db, - IDENTITY.sessionId, - 2, - JSON.stringify({ - v: 99, - kind: 'item', - epoch, - seq: 2, - fence: 1, - ts: 1, - itemId: 'future', - revision: 1, - body: { kind: 'status', text: 'from a newer build' } - }) - ) - await writeRemnant() - - const before = liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId) - - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - expect(liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId)).toEqual( - before - ) - }) - - // A row nothing projects is a row nobody reads. - it('renders in the transcript as a system line', async () => { - await writeRemnant() - - const journal = await open() - - const messages = projectStructuredItemsToNativeChat(journal.snapshot().items) - expect(messages).toHaveLength(1) - expect(messages[0]?.role).toBe('system') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts deleted file mode 100644 index 69dcc04bf64..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.ts +++ /dev/null @@ -1,60 +0,0 @@ -// A journal directory left behind by the pre-SQLite file format. -// -// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. -// This is Orca's own `log.jsonl`, which no build after the SQLite move reads. -// Nothing imports it, so the session it belonged to opens empty and is -// indistinguishable from a chat created seconds ago — same `session_created` -// epoch, same empty timeline. The remnant is the one durable fact that tells -// them apart, so the empty session says where its history went and how to carry -// on instead of silently claiming it never had any. - -import { existsSync } from 'node:fs' -import { join } from 'node:path' -import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import { boundJournalStatusText } from './journal-prompt-body-bounds' -import { formatAgentTypeLabel } from '../../../shared/agent-type-label' -import type { AgentType } from '../../../shared/agent-status-types' - -/** The remnant's transcript, or null when the directory never held one. - * - * `log.jsonl` first, and the order matters: every epoch roll staged a - * `snapshot.json` whether or not anything was ever compacted into it, so the - * file's existence says nothing about where the history lives. Measured across - * a real profile, `compactedThrough` was 0 in all 80 — the log holds the - * transcript and the snapshot is the fallback for a session that has no log. */ -export function findJournalFileFormatRemnant(journalDir: string): string | null { - for (const name of ['log.jsonl', 'snapshot.json']) { - const path = join(journalDir, name) - if (existsSync(path)) { - return path - } - } - return null -} - -/** One stable identity, so a reopen upserts the same row instead of adding one. */ -export const JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY: AgentJournalItemIdentity = { - provider: 'orca', - clientMessageId: 'journal-file-format-remnant' -} - -/** How to carry on. The session attaches on the record's own provider handle, so it - * still names the conversation the transcript no longer shows — whether the provider - * itself still holds that thread is its own business, hence "points at". */ -export function journalFileFormatRemnantDisclosure(input: { - transcriptPath: string - agent: AgentType -}): { identity: AgentJournalItemIdentity; body: { kind: 'status'; text: string } } { - return { - identity: JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY, - body: { - kind: 'status', - text: boundJournalStatusText( - `This chat's history was saved in an older format Orca no longer reads, so it starts ` + - `empty. The session still points at the same ${formatAgentTypeLabel(input.agent)} ` + - `conversation — send a message to pick up where you left off. The original ` + - `transcript is on the session's host at \`${input.transcriptPath}\`` - ) - } - } -} 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 ad151d10ec7..32b0c846571 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,7 +3,6 @@ // 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' @@ -26,7 +25,7 @@ export function openTestJournalHostDatabase(stateDirectory: string): JournalHost if (existing && !existing.isClosed) { return existing } - const database = JournalHostDatabase.openWith(directory, NO_LEGACY_JOURNAL_RECORDS) + const database = JournalHostDatabase.open(directory) opened.set(directory, database) return database } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index 4f209c2f202..dc2980d4833 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -184,14 +184,14 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, vi.spyOn(console, 'warn').mockImplementation(() => undefined) const stopFailing = failCommits(connection) - // A first-use copy's batch, under the unsynced level. Its caller gets the COMMIT's own error. + // Its caller gets the COMMIT's own error. expect(() => - database.unsyncedTransaction((db) => + database.transaction((db) => db .prepare( 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' ) - .run('copying', 'epoch-copying', 1, 1, '{}') + .run('unknown', 'epoch-unknown', 1, 1, '{}') ) ).toThrow('FOREIGN KEY constraint failed') expect(connection.isTransaction).toBe(true) @@ -210,8 +210,6 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, rollbackFails = false expect(database.db.isTransaction).toBe(false) stopFailing() - // Restored with the ROLLBACK: no later commit runs at the copy's unsynced level. - expect(Number(connection.pragma('synchronous', { simple: true }))).toBe(2) await expect( other.appendItem(item(1), text('served'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ).resolves.toBeDefined() 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 ca6036d3a9d..e87de366fff 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 @@ -6,20 +6,13 @@ import { mkdirSync } from 'node:fs' import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' 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' -import { journalDirectoryFor } from './journal-paths' import { AgentSessionJournalError } from './journal-write-guards' const JOURNAL_DATABASE_FILE = 'agent-session-journal.db' @@ -32,45 +25,17 @@ 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 - private constructor( - readonly stateDirectory: string, - opened: OpenJournalDatabase - ) { + private constructor(opened: OpenJournalDatabase) { this.connection = opened.db this.readOnly = opened.readOnly - this.legacyRecordImportOwed = opened.legacyRecordImportOwed } - /** `readLegacyRecords` runs only when this open migrates to version 4, before any transaction. */ - static async open( - stateDirectory: string, - readLegacyRecords: () => Promise - ): Promise { + static open(stateDirectory: string): JournalHostDatabase { 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), legacyRecords) - ) + return new JournalHostDatabase(openJournalDatabase(journalDatabasePath(stateDirectory))) } get isClosed(): boolean { @@ -95,31 +60,6 @@ export class JournalHostDatabase { }) } - /** - * The same transaction, committed without an fsync: for rows no reader follows until a later - * synced commit, which under WAL makes every earlier frame durable too. The setting is restored - * in the same task, so no other chat's commit runs under it. - */ - unsyncedTransaction(run: (db: Database.Database) => T): T { - const db = this.db - db.pragma('synchronous = NORMAL') - try { - return this.transaction(run) - } finally { - // SQLite refuses the change inside a transaction; freeing a stranded one restores it. - if (!db.isTransaction) { - db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) - } - } - } - - /** Where this chat's history lived before the journal was one database per host. */ - legacyDirectoryFor( - identity: Pick - ): string { - return journalDirectoryFor(this.stateDirectory, identity) - } - /** Last, after every store has drained. A close that fails keeps the handle, so the retried * teardown closes this same connection. */ close(): void { @@ -140,7 +80,6 @@ export class JournalHostDatabase { throw journalOpenRefusalError(error) } } - connection.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) this.stranded = false } } 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 c61057ac1b9..696059ecc44 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 } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' +import { openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, @@ -31,7 +31,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), NO_LEGACY_JOURNAL_RECORDS).db + const db = openJournalDatabase(journalDatabasePath(root)).db try { replayJournal(db, 'session-1') } finally { @@ -62,7 +62,7 @@ describe('classifyJournalOpenFailure', () => { it('calls a journal whose pages are damaged corrupt', async () => { const path = journalDatabasePath(root) - const opened = openJournalDatabase(path, NO_LEGACY_JOURNAL_RECORDS).db + const opened = openJournalDatabase(path).db opened.exec('PRAGMA journal_mode = DELETE') opened.close() const bytes = await readFile(path) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 8ae45ca4a31..73349ab0939 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -20,11 +20,6 @@ type JournalRefusalReason = AgentSessionRefusalReason<'agent_session_journal_unr /** Why an open failed, as the storage shows it; a newer Orca's journal is told apart first. */ export type JournalOpenFailure = Exclude -/** A per-chat file whose copy did not read back as the file: the history is not usable here. */ -export class JournalImportMismatchError extends Error { - override readonly name = 'JournalImportMismatchError' -} - /** A history whose rows are not what they promise: a row no build wrote, a gap, no epoch row. */ export class JournalDamageError extends Error { override readonly name = 'JournalDamageError' @@ -59,14 +54,13 @@ export class JournalUnreleasedSchemaError extends Error { // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 -/** Unusable only where proven: damage the storage or the rows show, a copy that did not verify, - * or a file only an unreleased build wrote. Anything unproven can clear. */ +/** Unusable only where proven: damage the storage or the rows show, or a file only an unreleased + * build wrote. Anything unproven can clear. */ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { let current = error for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { if ( isSqliteCorruption(current) || - current instanceof JournalImportMismatchError || current instanceof JournalDamageError || current instanceof JournalUnreleasedSchemaError ) { diff --git a/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts b/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts deleted file mode 100644 index 6f1b3e9dbc3..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-owed-import-test-support.ts +++ /dev/null @@ -1,112 +0,0 @@ -// A chat still in the per-chat file an earlier build left, opened the way startup restore opens -// one: its copy into the host's database is owed, and the chat's first write pays it before that -// write lands. It is the one backlog a chat's write queue really holds. - -import { mkdir } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import Database from '../../sqlite/sync-database' -import { - openTestJournalHostDatabase, - readTestJournalRows -} from './journal-host-database-test-support' -import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' -import type { JournalStoredRow } from './journal-row-table' -import type { AgentSessionJournal } from './journal-store' -import { openAgentSessionJournal } from './journal-store-factory' - -const OWED_IMPORT_HISTORY_TEXT = 'history from the earlier build' - -/** Opens `identity`'s chat from a per-chat file of real history rows, its copy still owed. */ -export async function openJournalOwingImport(input: { - stateDirectory: string - identity: AgentSessionJournalIdentity - now?: () => number -}): Promise<{ journal: AgentSessionJournal; history: JournalStoredRow[] }> { - const { stateDirectory, identity } = input - const history = await historyRows(input) - await writePerChatFile(journalDirectoryFor(stateDirectory, identity), identity, history) - const journal = await openAgentSessionJournal({ - identity, - database: openTestJournalHostDatabase(stateDirectory), - ...(input.now ? { now: input.now } : {}), - deferPerSessionImport: true - }) - if (!journal.importPending) { - throw new Error('the chat opened with its copy already made') - } - return { journal, history } -} - -/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ -async function historyRows(input: { - stateDirectory: string - identity: AgentSessionJournalIdentity - now?: () => number -}): Promise { - const scratch = join(input.stateDirectory, `scratch-${input.identity.sessionId}`) - const journal = await openAgentSessionJournal({ - identity: input.identity, - database: openTestJournalHostDatabase(scratch), - ...(input.now ? { now: input.now } : {}) - }) - await journal.appendSubmission({ - clientMessageId: 'client-history', - payloadFingerprint: 'fp-history', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1, - handoverRecorded: true - }) - await journal.appendItem( - { provider: 'codex', threadId: 'thread-history', turnId: 'turn-history', ordinal: 1 }, - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: OWED_IMPORT_HISTORY_TEXT }] - }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - return readTestJournalRows( - openTestJournalHostDatabase(scratch).db, - input.identity.sessionId, - journal.epoch - ) -} - -/** The per-chat file an earlier build left, in its own schema. */ -async function writePerChatFile( - directory: string, - identity: AgentSessionJournalIdentity, - rows: readonly JournalStoredRow[] -): Promise { - const path = legacyJournalDatabaseFile(directory) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.pragma('journal_mode = WAL') - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(identity.sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run( - identity.sessionId, - rows[0]!.epoch, - 1 - ) - } finally { - db.close() - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-paths.ts b/src/main/native-chat/agent-session-journal/journal-paths.ts deleted file mode 100644 index cb7d44e8c83..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-paths.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Where a chat's history lived when the journal was one database per chat. -// -// Nothing writes here any more: the host's one database replaced it. The importer copies a file it -// finds here on that chat's first use and deletes it once the copy verifies; the pre-SQLite format -// remnant check looks here too. -// Host-side per-workspace state, keyed by hashed ids — never inside the user's working tree. - -import { createHash } from 'node:crypto' -import { join } from 'node:path' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' - -const JOURNAL_DIR_NAME = 'agent-session-journal' - -/** Filesystem-safe, collision-resistant segment for an arbitrary id. */ -export function journalPathSegment(value: string): string { - return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32) -} - -/** `/agent-session-journal//`. */ -export function journalDirectoryFor( - root: string, - identity: Pick -): string { - return join( - root, - JOURNAL_DIR_NAME, - journalPathSegment(identity.workspaceId), - journalPathSegment(identity.sessionId) - ) -} - -const LEGACY_JOURNAL_DATABASE_FILE = 'journal.db' - -/** The per-chat SQLite file inside the directory `journalDirectoryFor` names. */ -export function legacyJournalDatabaseFile(journalDir: string): string { - return join(journalDir, LEGACY_JOURNAL_DATABASE_FILE) -} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts deleted file mode 100644 index 53397f87044..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts +++ /dev/null @@ -1,835 +0,0 @@ -// A chat's per-chat journal file from an earlier build is copied into the host's one database on -// that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that -// reappears after a downgrade is set aside on disk, and the chat keeps this build's history. - -import { - AGENT_JOURNAL_THREAD_SCOPE, - AGENT_SESSION_JOURNAL_SCHEMA_VERSION -} from '../../../shared/agent-session-journal-types' -import type * as NodeFs from 'node:fs' -import { existsSync, rmSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import type { SqliteRow } from '../../sqlite/sqlite-statement' -import Database from '../../sqlite/sync-database' -import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener, - liveTestJournalRows, - loadTestJournal, - openTestJournalHostDatabase, - readTestJournalRows, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' -import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' -import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' -import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' -import { createStructuredAgentSessionLogger } from '../agent-session-wire/structured-agent-session-logger' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -vi.mock('node:fs', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, rmSync: vi.fn(actual.rmSync) } -}) - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-legacy', - workspaceId: 'ws-1', - hostId: 'local', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function legacyDir(): string { - return journalDirectoryFor(root, IDENTITY) -} - -/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ -async function historyRows( - epoch = 'epoch-from-the-earlier-build', - reply = 'On it.' -): Promise<{ epoch: string; rows: JournalStoredRow[] }> { - const scratch = join(root, `scratch-${epoch}`) - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: scratch, - now: () => (clock += 1), - mintEpoch: () => epoch - }) - await journal.appendSubmission({ - clientMessageId: 'client-1', - payloadFingerprint: 'fp-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, - fence: 1, - handoverRecorded: true - }) - await journal.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: reply }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const rows = readTestJournalRows( - openTestJournalHostDatabase(scratch).db, - IDENTITY.sessionId, - journal.epoch - ) - return { epoch: journal.epoch, rows } -} - -/** The per-chat file an earlier build left, in its own schema. */ -async function writeLegacyJournal(epoch: string, rows: readonly JournalStoredRow[]): Promise { - const path = legacyJournalDatabaseFile(legacyDir()) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.pragma('journal_mode = WAL') - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(IDENTITY.sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - if (rows.length > 0) { - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) - } - } finally { - db.close() - } -} - -/** Whatever is left of the chat's per-chat directory, or a copy of it, beside it. */ -async function leftovers(): Promise { - const parent = dirname(legacyDir()) - const name = legacyDir().slice(parent.length + 1) - return existsSync(parent) ? (await readdir(parent)).filter((entry) => entry.startsWith(name)) : [] -} - -function texts(journal: { snapshot: () => { items: { body: unknown }[] } }): string { - return JSON.stringify(journal.snapshot().items.map((entry) => entry.body)) -} - -function rowCount(db: Database.Database): number { - return Number(db.prepare('SELECT count(*) AS total FROM journal_rows').get()?.total) -} - -function removeFails(): void { - vi.mocked(rmSync).mockImplementation(() => { - throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) - }) -} - -async function removeWorks(): Promise { - const actual = await vi.importActual('node:fs') - vi.mocked(rmSync).mockImplementation(actual.rmSync) -} - -/** The file as it is, except that the copy's first read of rows comes back through `alter`. */ -function alteringFirstCopiedRead( - alter: (rows: SqliteRow[]) => SqliteRow[] -): (path: string) => Database.Database { - return (path) => { - const source = new Database(path, { readonly: true, fileMustExist: true }) - const prepare = source.prepare.bind(source) - let altered = false - source.prepare = (sql: string) => { - const statement = prepare(sql) - if (altered || !sql.includes('seq > ?')) { - return statement - } - const all = statement.all.bind(statement) - // Why: prepare caches statements, so this one is handed out again after it was altered. - statement.all = (...args: Parameters) => { - const rows = all(...args) - if (altered) { - return rows - } - altered = true - return alter(rows) - } - return statement - } - return source - } -} - -const losingLastCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(0, -1)) - -/** Every row still there and still parsing, but the reply's words changed. */ -const garblingCopiedReply = alteringFirstCopiedRead((rows) => - rows.map((row) => ({ ...row, row_json: String(row.row_json).replace('On it.', 'On in.') })) -) - -function openChat() { - return journals.open({ identity: IDENTITY, stateDirectory: root, now: () => (clock += 1) }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-per-session-import-')) - clock = 1_000 -}) - -afterEach(async () => { - vi.restoreAllMocks() - await removeWorks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('importing a per-chat journal', () => { - it('copies the history verbatim on first open and deletes the file', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - - const journal = await openChat() - - expect(journal.epoch).toBe(epoch) - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect( - readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) - ).toEqual(rows) - // Verified, then deleted with its WAL files: no copy of it is kept. - expect(await leftovers()).toEqual([]) - }) - - // T-B3: the upgrade restart is the restart that produced the offers. A new epoch or renumbered - // rows would silently withdraw every "resume after update" offer. - it('keeps a restart offer taken before the upgrade', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. - const marker = { - sessionId: IDENTITY.sessionId, - journalCursor: { epoch, sequence: rows.length } - } as AgentSessionResumeMarker - - const journal = await openChat() - const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ - logger: createStructuredAgentSessionLogger(), - sessions: new Map([[IDENTITY.sessionId, { journal, child: null }]]), - now: () => clock, - enqueue: (operation) => operation() - }) - - expect(withdrawal.movedOn(marker)).toBe(false) - }) - - it('copies in batches between turns of the event loop, and publishes the chat with the last', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const turns: { published: boolean; copied: number }[] = [] - const tick = (): void => { - turns.push({ - published: readJournalSessionEpoch(database.db, IDENTITY.sessionId) !== null, - copied: rowCount(database.db) - }) - pending = setImmediate(tick) - } - let pending = setImmediate(tick) - - try { - await importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - } finally { - clearImmediate(pending) - } - - // Other work ran while rows were copied, and none of it could see a partly copied chat. - expect(turns.filter((turn) => !turn.published && turn.copied > 0).length).toBeGreaterThan(0) - expect(turns.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - }) - - // A quit between two batches: no reader sees the rows copied so far, and the next open copies the - // chat again from the start, with no duplicate or leftover row. - it('copies a chat again cleanly after a copy stopped midway', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - setImmediate(() => closeTestJournalHostDatabases()) - - await expect( - importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - ).rejects.toMatchObject({ code: 'journal_closed' }) - const reopened = openTestJournalHostDatabase(root) - expect(rowCount(reopened.db)).toBe(1) - expect(readJournalSessionEpoch(reopened.db, IDENTITY.sessionId)).toBeNull() - expect(loadTestJournal(root, IDENTITY.sessionId)).toBeNull() - - const other = await journals.open({ - identity: { ...IDENTITY, sessionId: 'session-other' }, - stateDirectory: root - }) - await other.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'another chat' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const journal = await openChat() - - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(readTestJournalRows(reopened.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(rowCount(reopened.db)).toBe( - rows.length + liveTestJournalRows(reopened.db, 'session-other').length - ) - }) - - it('copies a chat once when two opens of it import at the same time', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir(), batchRows: 1 } - - const outcomes = await Promise.all([ - importPerSessionJournal(input), - importPerSessionJournal(input) - ]) - - expect(outcomes).toEqual(['imported', 'absent']) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(rowCount(database.db)).toBe(rows.length) - }) - - // Only the copy's own batches skip the fsync: a live chat's write between them, and the publish - // that makes the batches durable, commit fully synced. - it('commits copy batches unsynced, and every other commit synced', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const synchronous = () => Number(database.db.pragma('synchronous', { simple: true })) - const transaction = database.transaction.bind(database) - const commits: number[] = [] - vi.spyOn(database, 'transaction').mockImplementation((run) => - transaction((db) => { - commits.push(synchronous()) - return run(db) - }) - ) - const between: number[] = [] - const tick = (): void => { - // What a live chat's append would commit under, between two copy batches. - between.push(synchronous()) - pending = setImmediate(tick) - } - - let pending = setImmediate(tick) - await importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - batchRows: 1 - }) - // A flag would still let the queued tick read the database after teardown closes it. - clearImmediate(pending) - - // 2 is FULL, 1 is NORMAL. - expect(commits.slice(0, rows.length)).toEqual(rows.map(() => 1)) - expect(commits.at(-1)).toBe(2) - expect(between.length).toBeGreaterThan(0) - expect(between.every((value) => value === 2)).toBe(true) - expect(synchronous()).toBe(2) - }) - - it('ends a copy on a turn of its own, so the open that replays it starts a new task', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - let turns = 0 - const tick = (): void => { - turns += 1 - } - - setImmediate(tick) - await importPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: IDENTITY, - legacyDirectory: legacyDir() - }) - - // One batch copies, and both verify reads are one batch each: only the final yield turns. - expect(turns).toBe(1) - }) - - it('keeps the file and refuses the chat when the copy does not read back as the file', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const input = { - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: losingLastCopiedRow - } - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(importPerSessionJournal(input)).rejects.toMatchObject({ - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - }) - } - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() - ).toMatchObject({ - total: 0 - }) - expect(errors).toHaveBeenCalledOnce() - // A copy that reads back whole then imports it, over what the refused ones left. - const journal = await openChat() - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(rowCount(database.db)).toBe(rows.length) - }) - - it('opens a file whose chat has no rows as a fresh chat, losing nothing', async () => { - const { epoch } = await historyRows() - await writeLegacyJournal(epoch, []) - const database = openTestJournalHostDatabase(root) - const db = new Database(legacyJournalDatabaseFile(legacyDir())) - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) - db.close() - - await expect( - previewPerSessionJournal({ database, identity: IDENTITY, legacyDirectory: legacyDir() }) - ).resolves.toBeNull() - const journal = await openChat() - - expect(journal.snapshot().items).toEqual([]) - expect(journal.cursor().sequence).toBe(1) - }) - - it('keeps a damaged file whole, copies none of it, and refuses the chat on every open', async () => { - const { epoch, rows } = await historyRows() - // A gap: the reply's row is gone from the file. - await writeLegacyJournal( - epoch, - rows.filter((row) => row.seq !== 2) - ) - const database = openTestJournalHostDatabase(root) - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - const unloadable = { - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - } - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } - - await expect(previewPerSessionJournal(input)).rejects.toMatchObject(unloadable) - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(importPerSessionJournal(input)).rejects.toMatchObject(unloadable) - } - await expect(openChat()).rejects.toMatchObject(unloadable) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(rowCount(database.db)).toBe(0) - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() - ).toMatchObject({ total: 0 }) - }) - - it("keeps a newer Orca's file whole, copies none of it, and refuses the chat as a newer Orca's", async () => { - const { epoch, rows } = await historyRows() - const last = rows.at(-1)! - const newer = { - ...last, - rowJson: JSON.stringify({ - ...JSON.parse(last.rowJson), - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 - }) - } - await writeLegacyJournal(epoch, [...rows.slice(0, -1), newer]) - const database = openTestJournalHostDatabase(root) - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - const input = { database, identity: IDENTITY, legacyDirectory: legacyDir() } - - await expect(previewPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - await expect(importPerSessionJournal(input)).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - await expect(openChat()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(rowCount(database.db)).toBe(0) - }) - - // A copy that keeps every count but not every byte is no copy: the file is all there is. - it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => { - const { epoch, rows } = await historyRows() - expect(rows.filter((row) => row.rowJson.includes('On it.'))).toHaveLength(1) - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const input = { - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: garblingCopiedReply - } - const before = await readFile(legacyJournalDatabaseFile(legacyDir())) - - await expect(importPerSessionJournal(input)).rejects.toMatchObject({ - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - }) - - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() - expect(errors).toHaveBeenCalledOnce() - }) - - // T-R2B1: the copy committed and only the delete failed (a crash between them is the same). Rows appended since, a restart, and a - // reopen with the file still there: nothing is copied again, and nothing is lost. - it('never copies the same file again after a failed delete, and deletes it on the next open', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - const journal = await openChat() - await journal.appendItem( - item(2), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after the upgrade' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - // The process exits and the database closes. - await journals.closeAll() - await removeWorks() - - const reopened = await openChat() - expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 }) - expect(texts(reopened)).toContain('after the upgrade') - expect(await leftovers()).toEqual([]) - }) - - // T-import-transient: a read that fails leaves the file for the next open, which imports it. - it('leaves the file in place on a failed read, and refuses the open rather than serve it empty', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - const database = openTestJournalHostDatabase(root) - const io = Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) - - await expect( - importPerSessionJournal({ - database, - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: () => { - throw io - } - }) - ).rejects.toThrow(io) - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - expect( - database.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() - ).toMatchObject({ total: 0 }) - - const journal = await openChat() - expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(existsSync(legacyDir())).toBe(false) - }) - - // A file that is not a database stays where it is, and the chat says it cannot be loaded. - it('refuses the open of a legacy file that is not a database, and keeps the file', async () => { - await mkdir(legacyDir(), { recursive: true }) - await writeFile(legacyJournalDatabaseFile(legacyDir()), 'not a database '.repeat(512)) - - await expect(openChat()).rejects.toMatchObject({ errcode: 26 }) - - expect(await readdir(legacyDir())).toEqual(['journal.db']) - }) - - it('leaves a never-written file in place until the chat it belongs to is founded', async () => { - await writeLegacyJournal('unused', []) - await mkdir(legacyDir(), { recursive: true }) - await writeFile(join(legacyDir(), 'log.jsonl'), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') - - const journal = await openChat() - - // The pre-SQLite transcript beside it is still there to explain the empty chat. - expect(JSON.stringify(journal.snapshot().items)).toContain('log.jsonl') - await journals.closeAll() - await openChat() - // The journal file goes; the transcript is the user's, and stays. - expect(await readdir(legacyDir())).toEqual(['log.jsonl']) - }) - - // A crash between creating the file and giving it the schema: the chat opens with no history, as - // it did when each chat opened its own file, and the file is deleted like any never-written one. - it.each([ - ['empty', async (path: string) => writeFile(path, '')], - [ - 'schema-less', - async (path: string) => { - const db = new Database(path) - db.pragma('journal_mode = WAL') - db.close() - } - ] - ])('opens a chat whose per-chat file is %s as having no history', async (_fileState, create) => { - await mkdir(legacyDir(), { recursive: true }) - await create(legacyJournalDatabaseFile(legacyDir())) - - const journal = await openChat() - - expect(journal.snapshot().items).toEqual([]) - await journals.closeAll() - await openChat() - expect(await leftovers()).toEqual([]) - }) - - // F-L6-1: the copy deleted the file, so the older build found none and started the chat over. - // The re-upgrade must neither let that start replace the history nor delete what it wrote. - it('keeps the history, and the older build’s file untouched, after a downgrade round trip', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - expect(texts(await openChat())).toContain('ORIGINAL HISTORY') - await journals.closeAll() - expect(existsSync(legacyDir())).toBe(false) - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const file = legacyJournalDatabaseFile(legacyDir()) - const bytes = await readFile(file) - - for (let open = 0; open < 2; open += 1) { - const reopened = await openChat() - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('typed in the older build') - await journals.closeAll() - } - const database = openTestJournalHostDatabase(root) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, first.epoch)).toEqual(first.rows) - expect((await readFile(file)).equals(bytes)).toBe(true) - const kept = new Database(file, { readonly: true }) - const keptRows = kept.prepare('SELECT seq, ts, row_json FROM journal_rows ORDER BY seq').all() - kept.close() - expect(keptRows.map((row) => row.row_json)).toEqual(older.rows.map((row) => row.rowJson)) - }) - - // The older build started over and then rewound, so its file's epoch opens `handle_forked`: it - // still never held this build's history, and is set aside like any other epoch. - it('keeps the history when the older build rewound the chat it started over', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - await openChat() - await journals.closeAll() - const older = await historyRows('epoch-older-rewound', 'typed in the older build') - const opening = JSON.parse(older.rows[0]!.rowJson) - expect(opening).toMatchObject({ kind: 'epoch', reason: 'session_created' }) - const rewound = [ - { ...older.rows[0]!, rowJson: JSON.stringify({ ...opening, reason: 'handle_forked' }) }, - ...older.rows.slice(1) - ] - await writeLegacyJournal(older.epoch, rewound) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('typed in the older build') - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - // The one file at another epoch that did descend from the copy: its delete failed and the older - // build rolled the epoch of the file it kept. It is set aside too; nothing this build has is lost. - it('sets aside a kept file the older build rolled to a new epoch', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await openChat() - await journals.closeAll() - await removeWorks() - const rolled = await historyRows('epoch-rolled-by-older', 'rolled in the older build') - await rm(legacyDir(), { recursive: true, force: true }) - await writeLegacyJournal(rolled.epoch, rolled.rows) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).not.toContain('rolled in the older build') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - }) - - // A chat this build founded has a pointer and no import marker: an older build that then starts - // a per-chat file for it never held this build's history, so the file is set aside. - it('keeps a chat founded in this build when an older build starts a per-chat file for it', async () => { - const founded = await openChat() - await founded.appendItem( - item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'FOUNDED HERE' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const foundedEpoch = founded.epoch - await journals.closeAll() - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - - const reopened = await openChat() - - expect(reopened.epoch).toBe(foundedEpoch) - expect(texts(reopened)).toContain('FOUNDED HERE') - expect(texts(reopened)).not.toContain('typed in the older build') - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - // Decided once and recorded: no later open reads the file again, after a restart or after the - // older build ran again and wrote more to it. - it('never reads a set-aside file again', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - await openChat() - await journals.closeAll() - const older = await historyRows('epoch-older-fresh', 'typed in the older build') - await writeLegacyJournal(older.epoch, older.rows) - const reads: string[] = [] - const countingSource = (path: string) => { - reads.push(path) - return new Database(path, { readonly: true, fileMustExist: true }) - } - const importAgain = () => - importPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: IDENTITY, - legacyDirectory: legacyDir(), - openSource: countingSource - }) - - expect(await importAgain()).toBe('kept') - expect(reads).toHaveLength(1) - closeTestJournalHostDatabases() - expect(await importAgain()).toBe('kept') - const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) - legacy.close() - expect(await importAgain()).toBe('kept') - - expect(reads).toHaveLength(1) - expect(texts(await openChat())).toContain('ORIGINAL HISTORY') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - }) - - // T-B5: a downgrade, an older build starting the chat over in a new per-chat file, and a - // re-upgrade — twice. This build's history stays whole each time, and the file stays on disk. - it('keeps this build’s history on every re-upgrade after an older build started the chat over', async () => { - const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') - await writeLegacyJournal(first.epoch, first.rows) - const upgraded = await openChat() - await upgraded.appendItem( - item(2), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'this build' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journals.closeAll() - const older = await historyRows('epoch-downgrade', 'older build, cycle 1') - await writeLegacyJournal(older.epoch, older.rows) - - for (const cycle of [1, 2]) { - if (cycle === 2) { - // The second downgrade finds the file it wrote the first time, and carries it on. - const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } - row.body = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'cycle 2' }] - } - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) - legacy.close() - } - const reopened = await openChat() - expect(reopened.epoch).toBe(first.epoch) - expect(texts(reopened)).toContain('ORIGINAL HISTORY') - expect(texts(reopened)).toContain('this build') - expect(texts(reopened)).not.toContain('older build, cycle 1') - expect(texts(reopened)).not.toContain('cycle 2') - expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - await journals.closeAll() - } - }) - - // The delete failed, so the older build found the copied file and carried its epoch on, while - // this build wrote nothing past the copy. This build's history still wins: the file is set aside. - it('sets aside a file an older build carried on under the copied epoch', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await openChat() - await journals.closeAll() - appendOlderRow(epoch, rows, rows.length + 1) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - await removeWorks() - - const reopened = await openChat() - - expect(reopened.epoch).toBe(epoch) - expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length }) - expect(texts(reopened)).not.toContain('older') - expect(sharedRowsContaining('older')).toBe(0) - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - /** The older build appends one row to the per-chat file the failed delete left, at its epoch. */ - function appendOlderRow(epoch: string, rows: readonly JournalStoredRow[], seq: number): void { - const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq } - olderRow.body = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'older' }] - } - olderRow.itemId = `${olderRow.itemId}-older` - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, epoch, seq, 1, JSON.stringify(olderRow)) - legacy.close() - } - - function sharedRowsContaining(text: string): number { - return openTestJournalHostDatabase(root) - .db.prepare('SELECT row_json FROM journal_rows') - .all() - .filter((row) => String(row.row_json).includes(text)).length - } -}) diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts deleted file mode 100644 index 66f1f139ea3..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts +++ /dev/null @@ -1,304 +0,0 @@ -// Copying a chat's per-chat journal file into the host's one database, on that chat's open. -// -// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. This reads Orca's own -// earlier `/journal.db`, verbatim: the same epoch UUID and every sequence number, so a -// cursor, an `acceptedSequence` or a restart offer taken before the upgrade still points at the -// same row after it. A file that reappears after a downgrade is set aside, never read again (see -// journal-per-session-reimport.ts). -// -// The copy runs in bounded batches, each its own transaction, yielding the event loop between them. -// The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader -// sees them. Once they read back as the file does (every row's sequence, time and bytes), one -// transaction publishes the chat's pointer with its import marker, so the chat is imported all at -// once or not at all. A try that stops midway leaves only unpublished rows, which the next try -// deletes before it copies again. A copy that does not read back as the file, or a file whose -// history is damaged, is never published: the file stays, and the chat is refused as unloadable. -// -// Only after that commit is the file deleted, its connection closed first. A read that fails -// leaves the file where it is for the next open, and the open is refused rather than served empty: -// an empty chat founded here would take a new epoch the next open's import could not reconcile. - -import { createHash } from 'node:crypto' -import { existsSync } from 'node:fs' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' -import type { JournalHostDatabase } from './journal-host-database' -import { startJournalRowFold, type JournalLoad } from './journal-open' -import { - failLoadOnUnloadableJournal, - JournalImportMismatchError, - journalOpenRefusalError -} from './journal-open-failure' -import { legacyJournalDatabaseFile } from './journal-paths' -import { - planPerSessionImport, - isPerSessionJournalSetAside, - readPerSessionImportMarker, - setAsidePerSessionJournal, - writePerSessionImportMarker, - type PerSessionImportPlan, - type PerSessionJournalHead -} from './journal-per-session-reimport' -import { - foldLegacyJournal, - IMPORT_BATCH_ROWS, - legacyRowBatches, - openLegacySource, - readLegacyHead, - retireLegacyJournal, - type ImportBatch -} from './journal-per-session-source' -import { parseJournalRow } from './journal-row-schema' -import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { - deleteUnpublishedJournalRows, - publishJournalSessionEpoch, - readJournalRowsAfter, - readJournalSessionEpoch -} from './journal-row-table' - -const INSERT_ROW = - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - -type PerSessionJournalImportDeps = { - openSource?: (path: string) => Database.Database - /** Deletes one of the per-chat files. */ - remove?: (path: string) => void - batchRows?: number -} - -export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' | 'kept' - -type ImportInput = { - database: JournalHostDatabase - identity: AgentSessionJournalIdentity - legacyDirectory: string -} & PerSessionJournalImportDeps - -/** Imports in flight, by database and chat: a second open of the same chat waits for the first. */ -const importsInFlight = new WeakMap>>() - -export function importPerSessionJournal( - input: ImportInput -): Promise { - let inFlight = importsInFlight.get(input.database) - if (!inFlight) { - inFlight = new Map() - importsInFlight.set(input.database, inFlight) - } - const { sessionId } = input.identity - const run = (inFlight.get(sessionId) ?? Promise.resolve()).then(() => importOnce(input)) - const settled = run.catch(() => undefined) - inFlight.set(sessionId, settled) - void settled.then(() => { - if (inFlight.get(sessionId) === settled) { - inFlight.delete(sessionId) - } - }) - return run -} - -async function importOnce(input: ImportInput): Promise { - const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) - if (!existsSync(sourcePath)) { - return 'absent' - } - const { sessionId } = input.identity - if (isPerSessionJournalSetAside(input.database.db, sessionId)) { - return 'kept' - } - const published = readJournalSessionEpoch(input.database.db, sessionId) !== null - const source = (input.openSource ?? openLegacySource)(sourcePath) - let legacy: PerSessionJournalHead | null - let plan: PerSessionImportPlan | null = null - try { - legacy = readLegacyHead(source, sessionId) - if (legacy) { - plan = planPerSessionImport({ db: input.database.db, sessionId, legacy, published }) - if (plan.kind === 'first') { - await copyLegacyJournal(input, source, legacy) - } - } - } finally { - source.close() - } - if (!legacy) { - // Never written. Left in place while its chat is unfounded: that open's empty chat may still - // owe the notice about a pre-SQLite transcript beside it. - if (!published) { - return 'absent' - } - retireLegacyJournal(input.legacyDirectory, input.remove) - return 'already-imported' - } - if (plan?.kind === 'kept') { - setAsidePerSessionJournal(input.database.db, sessionId, legacy) - return 'kept' - } - // Also a file a crash left after its copy was recorded (`copied`): deleted now, not copied again. - retireLegacyJournal(input.legacyDirectory, input.remove) - if (plan?.kind === 'copied') { - return 'already-imported' - } - // The open's replay of what was just copied is a long task of its own; don't add this one to it. - await yieldToEventLoop() - return 'imported' -} - -/** - * The chat a first copy would import, folded straight from its per-chat file and copying nothing: - * for a restore, which must not import. Null when the open has to import now instead: the chat is - * already in the host's database or was copied before (the reimport rules decide), its file holds - * no chat or no rows, or a newer build wrote it. A damaged file fails the load, copied nowhere. The - * file is closed before this returns. - */ -export async function previewPerSessionJournal( - input: Pick -): Promise { - const { sessionId } = input.identity - const db = input.database.db - const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) - if ( - readJournalSessionEpoch(db, sessionId) !== null || - readPerSessionImportMarker(db, sessionId) || - !existsSync(sourcePath) - ) { - return null - } - const source = (input.openSource ?? openLegacySource)(sourcePath) - try { - const legacy = readLegacyHead(source, sessionId) - if (!legacy) { - return null - } - const loaded = await foldLegacyJournal(source, sessionId, legacy) - failLoadOnUnloadableJournal(sessionId, loaded) - // An empty one is copied now, so its open founds the chat's epoch in the host's database. - return loaded.state.lastSequence === 0 ? null : loaded - } finally { - source.close() - } -} - -/** - * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Each - * batch is folded as it is copied, so a damaged file is refused before anything is published. Once - * the rows read back as the file does, one transaction publishes the pointer and the import marker. - */ -async function copyLegacyJournal( - input: ImportInput, - source: Database.Database, - legacy: PerSessionJournalHead -): Promise { - const { sessionId } = input.identity - const { epoch } = legacy - const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - const fold = startJournalRowFold({ sessionId, epoch }) - let folding = true - let first = true - for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) { - if (!first) { - await yieldToEventLoop() - } - folding &&= batch.rows.every(fold.add) - // Unsynced: no reader follows these rows, and the publish's synced commit covers them. - input.database.unsyncedTransaction((db) => { - if (first) { - // What an earlier try that stopped midway left. - deleteUnpublishedJournalRows(db, sessionId) - } - const insert = db.prepare(INSERT_ROW) - for (const row of batch.rows) { - // Copied as stored: the bytes are the row, its epoch and sequence included. - insert.run(sessionId, epoch, row.seq, row.ts, row.rowJson) - } - }) - first = false - } - const copied = fold.finish() - if (copied.damage || copied.newer) { - // Never published, so the copy goes; the file it came from stays. - input.database.transaction((db) => deleteUnpublishedJournalRows(db, sessionId)) - } - failLoadOnUnloadableJournal(sessionId, copied) - await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch) - input.database.transaction((db) => { - publishJournalSessionEpoch(db, input.identity, epoch) - writePerSessionImportMarker(db, sessionId, legacy) - }) -} - -/** Mismatches already logged, so a chat refused on every open logs once. */ -const loggedMismatches = new Set() - -/** - * The copied rows, read back from the host's database, against a second read of what was copied: - * the same rows, byte for byte, and the same epoch, tip, row count, items and submissions, or the - * copy is refused and never published. Both reads go a batch at a time, so no check holds the main - * thread longer than a copy batch does. - */ -async function verifyCopiedJournal( - input: ImportInput, - expected: Iterable, - epoch: string -): Promise { - const { sessionId } = input.identity - const want = await copyFacts(sessionId, expected) - const got = await copyFacts(sessionId, copiedBatches(input, epoch)) - if (want === got) { - return - } - const error = new JournalImportMismatchError( - `per-chat journal of ${sessionId} read back as ${got} after its copy, not ${want}` - ) - if (!loggedMismatches.has(`${sessionId}\n${want}\n${got}`)) { - loggedMismatches.add(`${sessionId}\n${want}\n${got}`) - console.error(`[agent-session-journal] ${error.message}; ${input.legacyDirectory} is kept`) - } - throw journalOpenRefusalError(error) -} - -/** Epoch, tip, row count, items, submissions and a digest of every row, folded a batch at a time. */ -async function copyFacts(sessionId: string, batches: Iterable): Promise { - const state = createJournalReducerState(sessionId, '') - const content = createHash('sha256') - let epoch: string | null = null - let tip = 0 - let rows = 0 - let first = true - for (const batch of batches) { - if (!first) { - await yieldToEventLoop() - } - first = false - rows += batch.rows.length - for (const row of batch.rows) { - tip = Math.max(tip, row.seq) - // Length-framed, so no two different rows hash the same stream. - content.update(`${row.seq}:${row.ts}:${row.rowJson.length}:`).update(row.rowJson) - const parsed = parseJournalRow(row.rowJson) - if (parsed.ok) { - epoch ??= parsed.row.epoch - applyJournalRow(state, parsed.row) - } - } - } - return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}:${content.digest('hex')}` -} - -function* copiedBatches(input: ImportInput, epoch: string): Generator { - const { sessionId } = input.identity - const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - let afterSeq = Number.MIN_SAFE_INTEGER - for (;;) { - const rows = readJournalRowsAfter(input.database.db, sessionId, epoch, afterSeq, batchRows) - const lastSeq = rows.at(-1)?.seq - const last = rows.length < batchRows || lastSeq === undefined - yield { rows, last } - if (last) { - return - } - afterSeq = lastSeq - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts b/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts deleted file mode 100644 index 61ddbfc98f1..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts +++ /dev/null @@ -1,73 +0,0 @@ -// A per-chat file that reappears after its chat was copied in: an older build, run after a -// downgrade, attached the chat and wrote its history there. -// -// `journal_imports` records which file each chat was copied from — its epoch and tip — in the -// transaction that publishes the verified copy. A file still at that epoch and tip was already -// copied (only its delete failed, or a crash came first, across any number of restarts): it is -// deleted, never copied again. This build's history always wins: once a chat has been copied or -// founded here, any other file is set aside, left on disk as it is, and recorded in -// `journal_set_aside`, so no later open reads it again. - -import type Database from '../../sqlite/sync-database' - -export type PerSessionJournalHead = { epoch: string; tip: number } - -const SELECT_MARKER = 'SELECT epoch, tip FROM journal_imports WHERE session_id = ?' -const UPSERT_MARKER = `INSERT INTO journal_imports (session_id, epoch, tip) VALUES (?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, tip = excluded.tip` - -export function readPerSessionImportMarker( - db: Database.Database, - sessionId: string -): PerSessionJournalHead | null { - const row = db.prepare(SELECT_MARKER).get(sessionId) - return typeof row?.epoch === 'string' && typeof row.tip === 'number' - ? { epoch: row.epoch, tip: row.tip } - : null -} - -const SELECT_SET_ASIDE = 'SELECT 1 AS present FROM journal_set_aside WHERE session_id = ?' -const INSERT_SET_ASIDE = `INSERT INTO journal_set_aside (session_id, epoch, tip) VALUES (?, ?, ?) -ON CONFLICT(session_id) DO NOTHING` - -export function isPerSessionJournalSetAside(db: Database.Database, sessionId: string): boolean { - return db.prepare(SELECT_SET_ASIDE).get(sessionId) !== undefined -} - -/** Records a file that is not this build's history, as it was when set aside. */ -export function setAsidePerSessionJournal( - db: Database.Database, - sessionId: string, - head: PerSessionJournalHead -): void { - db.prepare(INSERT_SET_ASIDE).run(sessionId, head.epoch, head.tip) -} - -export function writePerSessionImportMarker( - db: Database.Database, - sessionId: string, - head: PerSessionJournalHead -): void { - db.prepare(UPSERT_MARKER).run(sessionId, head.epoch, head.tip) -} - -export type PerSessionImportPlan = - | { kind: 'first' } - | { kind: 'copied' } - /** Not this build's history: set aside, neither copied nor deleted. */ - | { kind: 'kept' } - -/** What a present per-chat file owes this chat, judged against what was last copied from it. */ -export function planPerSessionImport(input: { - db: Database.Database - sessionId: string - legacy: PerSessionJournalHead - /** The chat already has an epoch in the host's database. */ - published: boolean -}): PerSessionImportPlan { - const marker = readPerSessionImportMarker(input.db, input.sessionId) - if (marker?.epoch === input.legacy.epoch && marker.tip === input.legacy.tip) { - return { kind: 'copied' } - } - return !marker && !input.published ? { kind: 'first' } : { kind: 'kept' } -} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts deleted file mode 100644 index a58a1de3bf5..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts +++ /dev/null @@ -1,126 +0,0 @@ -// Reading a chat's per-chat journal file, the one each chat had before the host's one database. -// The importer copies through this reader, and a restore folds through it without copying. - -import { rmdirSync, rmSync } from 'node:fs' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' -import Database from '../../sqlite/sync-database' -import { startJournalRowFold, type JournalLoad } from './journal-open' -import { legacyJournalDatabaseFile } from './journal-paths' -import type { PerSessionJournalHead } from './journal-per-session-reimport' - -/** The newest per-chat file shape any build wrote. */ -const LEGACY_JOURNAL_SCHEMA_VERSION = 2 -/** Rows per batch: at most 31 ms per batch copying the largest real chat (68 MB, 3.3 KB rows). */ -export const IMPORT_BATCH_ROWS = 512 - -const SELECT_LEGACY_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' -const SELECT_LEGACY_TIP = - 'SELECT max(seq) AS tip FROM journal_rows WHERE session_id = ? AND epoch = ?' -const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows -WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?` -const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?" - -type ImportedRow = { seq: number; ts: number; rowJson: string } -export type ImportBatch = { rows: ImportedRow[]; last: boolean } - -/** A plain read-only connection: it sees committed WAL frames without checkpointing them. */ -export function openLegacySource(path: string): Database.Database { - const source = new Database(path, { readonly: true, fileMustExist: true }) - try { - const version = Number(source.pragma('user_version', { simple: true }) ?? 0) - if (version > LEGACY_JOURNAL_SCHEMA_VERSION) { - throw new Error(`per-chat journal ${path} uses schema ${version}, which no build wrote`) - } - return source - } catch (error) { - source.close() - throw error - } -} - -export function readLegacyHead( - source: Database.Database, - sessionId: string -): PerSessionJournalHead | null { - // Created but never given its schema (a crash between the two): no history, as an empty file. - if (!source.prepare(HAS_LEGACY_TABLE).get('journal_sessions')) { - return null - } - const epoch = source.prepare(SELECT_LEGACY_EPOCH).get(sessionId)?.epoch - if (typeof epoch !== 'string' || epoch.length === 0) { - return null - } - const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip - return { epoch, tip: typeof tip === 'number' ? tip : 0 } -} - -/** The file's rows, one bounded page per batch, read as each batch is written. */ -export function* legacyRowBatches( - source: Database.Database, - sessionId: string, - epoch: string, - batchRows: number -): Generator { - const select = source.prepare(SELECT_LEGACY_ROWS) - let afterSeq = Number.MIN_SAFE_INTEGER - for (;;) { - const rows = select - .all(sessionId, epoch, afterSeq, batchRows) - .map((row) => ({ seq: Number(row.seq), ts: Number(row.ts), rowJson: String(row.row_json) })) - const lastSeq = rows.at(-1)?.seq - const last = rows.length < batchRows || lastSeq === undefined - yield { rows, last } - if (last) { - return - } - afterSeq = lastSeq - } -} - -/** The chat as its file holds it, folded the way a replay folds the host's database. */ -export async function foldLegacyJournal( - source: Database.Database, - sessionId: string, - legacy: PerSessionJournalHead -): Promise { - const fold = startJournalRowFold({ sessionId, epoch: legacy.epoch }) - let first = true - for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) { - // A batch per turn: a large chat's file read in one task holds up everything else at startup. - if (!first) { - await yieldToEventLoop() - } - first = false - if (!batch.rows.every(fold.add)) { - break - } - } - return fold.finish() -} - -/** - * Deletes the per-chat file and SQLite's WAL files beside it, then the directory if nothing else is - * in it: a pre-SQLite transcript there is the user's, and stays. Its connection must be closed. - * Best effort: the copy is committed, so a file left behind is deleted by the next open. - */ -export function retireLegacyJournal( - legacyDirectory: string, - remove: (path: string) => void = (path) => rmSync(path, { force: true }) -): void { - const file = legacyJournalDatabaseFile(legacyDirectory) - try { - // The database first: a WAL left without it is never read, but a database left without its WAL - // would read back short of the tip it was copied at, and be set aside rather than deleted. - for (const path of [file, `${file}-wal`, `${file}-shm`]) { - remove(path) - } - } catch (error) { - console.warn(`[agent-session-journal] deleting imported ${legacyDirectory} failed`, error) - return - } - try { - rmdirSync(legacyDirectory) - } catch { - // Not empty: something beside the journal is kept. - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts index 4aa27599434..d8c5c1737b7 100644 --- a/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts +++ b/src/main/native-chat/agent-session-journal/journal-prompt-body-bounds.ts @@ -36,10 +36,6 @@ export function cancelledJournalPromptBody( } } -export function boundJournalStatusText(text: string): string { - return boundInlineText(text, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text -} - export function boundJournalPromptBody(body: AgentJournalApprovalItem): AgentJournalApprovalItem export function boundJournalPromptBody(body: AgentJournalQuestionItem): AgentJournalQuestionItem export function boundJournalPromptBody( diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 72f2b2fec0a..bb6eb2ef4d7 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -344,7 +344,7 @@ export class JournalQueuedMessages { * no hook), then retention runs. */ repairAndPrune(): Promise { - // No draft, no work, and no write: a chat whose first-use copy is still owed stays uncopied. + // No draft, no work, and no write. if (this.deps.readOnly() || this.list().length === 0) { return Promise.resolve() } diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index a558252a899..9074ae0019e 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -21,8 +21,6 @@ const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?' -const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ? -AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)` export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null { const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch @@ -106,8 +104,3 @@ export function deleteJournalEpochRows( ): void { db.prepare(DELETE_EPOCH).run(sessionId, epoch) } - -/** Rows of this chat under any epoch its pointer does not name: a copy that never published. */ -export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void { - db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId) -} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 395ad50a6ee..600d673b6d8 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -30,15 +30,9 @@ export type JournalStoreHost = { * same way they learn of a row. */ notifyCommitted: () => void identity: AgentSessionJournalIdentity - /** Where the chat's per-chat history lived, for the importer and the format-remnant notice. */ - legacyDirectory: string now: () => number mintEpoch: () => string serialize: (run: JournalWriteBody) => Promise - /** Leave a chat still in its per-chat file uncopied until its first use. */ - deferPerSessionImport: boolean - /** Work the chat's next write waits for. */ - owe: (work: () => Promise) => void database: () => JournalHostDatabase state: () => JournalReducerState readOnly: () => boolean diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index 2d7b56d06b1..7492ea7e461 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -21,8 +21,6 @@ export type AgentSessionJournalOptions = { database: JournalHostDatabase now?: () => number mintEpoch?: () => string - /** A restore's open: see `AgentSessionJournal.whenImported`. */ - deferPerSessionImport?: boolean } export type JournalReadSince = diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index cac1517c409..1a5db4fe0d2 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -2,20 +2,12 @@ import type { AgentJournalItemBody, AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' -import type { AgentType } from '../../../shared/agent-status-types' -import { - findJournalFileFormatRemnant, - journalFileFormatRemnantDisclosure -} from './journal-file-format-remnant' import type { JournalLoad } from './journal-open' import { failLoadOnUnloadableJournal } from './journal-open-failure' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' -type JournalDisclosure = ReturnType - export async function openJournalStoreState(input: { sessionId: string - legacyDirectory: string replay: () => JournalLoad | null start: () => void adopt: (loaded: JournalLoad) => void @@ -24,7 +16,6 @@ export async function openJournalStoreState(input: { body: AgentJournalItemBody, fence: number ) => Promise - agent: AgentType highestFence: () => number }): Promise { const loaded = input.replay() @@ -32,41 +23,11 @@ export async function openJournalStoreState(input: { // keep, so it is founded afresh like a chat with no journal; no row is deleted. if (!loaded || (!loaded.newer && !loaded.damage && loaded.state.lastSequence === 0)) { input.start() - await discloseFileFormatRemnant(input) return } failLoadOnUnloadableJournal(input.sessionId, loaded) input.adopt(loaded) await settleStaleSubagentRosters(input, loaded) - // Founding the epoch and appending the row are two transactions, and a - // committed epoch sends every later open down this branch instead. Anything - // that interrupts between them — a quit during startup restore, a failed - // append — would otherwise lose the message for good. An epoch holding nothing - // is exactly the state that append was owed, so offer it again. - if (loaded.state.items.size === 0 && loaded.state.submissions.size === 0) { - await discloseFileFormatRemnant(input) - } -} - -/** Says what happened to a chat whose history is in the abandoned file format. - * Upserts by a constant identity, so the offer above is exactly-once in effect: - * once the row exists the epoch is no longer empty. */ -async function discloseFileFormatRemnant(input: { - legacyDirectory: string - agent: AgentType - appendItem: ( - identity: JournalDisclosure['identity'], - body: JournalDisclosure['body'], - fence: number - ) => Promise - highestFence: () => number -}): Promise { - const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory) - if (!transcriptPath) { - return - } - const disclosure = journalFileFormatRemnantDisclosure({ transcriptPath, agent: input.agent }) - await input.appendItem(disclosure.identity, disclosure.body, input.highestFence()) } /** Retires a `working` subagent roster the previous host never got to settle. */ diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index d8c44f964bd..0f5208dea9b 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -1,7 +1,7 @@ // Bringing a store's in-memory state up from disk. // // Split out of the store for the same reason its collaborators were: this is the -// ORDERING between import, replay and disclosure, and none of it belongs +// ORDERING between replay and the notices an open owes, and none of it belongs // to the store's public surface. Every step here reads or writes through the // same host the collaborators use, so the store keeps the state and this owns // the sequence. @@ -9,22 +9,17 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' import { replayJournal } from './journal-open' -import { failLoadOnUnloadableJournal, journalOpenRefusalError } from './journal-open-failure' +import { journalOpenRefusalError } from './journal-open-failure' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' -import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' import { AgentSessionJournalError } from './journal-write-guards' export async function restoreJournalStore( host: JournalStoreHost, collaborators: { epochController: JournalEpochController } ): Promise { - const source = { - database: host.database(), - identity: host.identity, - legacyDirectory: host.legacyDirectory - } - if (source.database.readOnly) { + const database = host.database() + if (database.readOnly) { // A newer Orca's database: nothing in it is read as this build's, and nothing is written. throw journalOpenRefusalError( new AgentSessionJournalError( @@ -33,32 +28,14 @@ export async function restoreJournalStore( ) ) } - // A restore reads a chat still in its per-chat file from there, and copies it before its first use. - const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null - if (preview) { - host.owe(async () => { - await importPerSessionJournal(source) - const imported = replayJournal(source.database.db, host.identity.sessionId) - if (!imported) { - throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`) - } - failLoadOnUnloadableJournal(host.identity.sessionId, imported) - host.adopt(imported) - }) - } else { - // A per-chat file left by an earlier build is this chat's newest history: copied in first. - await importPerSessionJournal(source) - } return openJournalStoreState({ sessionId: host.identity.sessionId, - legacyDirectory: host.legacyDirectory, - replay: () => preview ?? replayJournal(host.database().db, host.identity.sessionId), + replay: () => replayJournal(database.db, host.identity.sessionId), start: () => collaborators.epochController.start('session_created', 0), adopt: host.adopt, - // File-format and roster notices are about the conversation, not any turn in it. + // Roster notices are about the conversation, not any turn in it. appendItem: (identity, body, fence) => host.journal().appendItem(identity, body, { fence, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), - agent: host.identity.agent, highestFence: () => host.state().highestFence }) } diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index a3bd5be348c..5c5637de2df 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -19,7 +19,6 @@ import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { journalDirectoryFor, journalPathSegment } from './journal-paths' import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store' import type { openAgentSessionJournal } from './journal-store-factory' import { @@ -441,27 +440,6 @@ describe('lifecycle batches', () => { }) }) -describe('journal location', () => { - it('keys by workspace and session id rather than by a path in the working tree', () => { - const dir = journalDirectoryFor('/state', { workspaceId: 'ws/1', sessionId: 'sess:2' }) - expect(dir).toBe( - join( - '/state', - 'agent-session-journal', - journalPathSegment('ws/1'), - journalPathSegment('sess:2') - ) - ) - expect(dir).not.toContain('ws/1') - }) - - it('separates two sessions in one workspace', () => { - const a = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'a' }) - const b = journalDirectoryFor('/state', { workspaceId: 'ws', sessionId: 'b' }) - expect(a).not.toBe(b) - }) -}) - describe('on-disk layout', () => { it('keeps every chat of the state directory in its one database, and no per-chat file', async () => { const journal: AgentSessionJournal = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 146c141bda0..7548f6e5502 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -107,12 +107,9 @@ export class AgentSessionJournal { this.queue = new JournalWriteQueue(options.identity.sessionId) const collaborators = createJournalStoreCollaborators({ identity: this.identity, - legacyDirectory: this.database.legacyDirectoryFor(this.identity), now: this.now, mintEpoch: this.mintEpoch, serialize: (run) => this.queue.serialize(run), - deferPerSessionImport: options.deferPerSessionImport === true, - owe: (work) => this.queue.owe(work), database: () => this.database, state: () => this.state, // A newer Orca's database: nothing it holds opens, and every write is refused. @@ -177,20 +174,6 @@ export class AgentSessionJournal { this.onCommitted = listener } - /** - * Resolves once the chat's rows are in the host's database. A restore's open serves a chat still - * in its per-chat file from a read-only fold of it; the copy runs before the chat's first write. - * A reader that needs rows (forward pages, catch-up) and every mutation's open await it here, so - * each reads the fold after every earlier write. - */ - whenImported(): Promise { - return this.queue.serialize(() => undefined) - } - - get importPending(): boolean { - return this.queue.owing - } - cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -264,7 +247,7 @@ export class AgentSessionJournal { canonicalItemId = (itemId: string): string => resolveJournalItemId(this.state, itemId) /** Reads the fold with every write issued before this call committed, and none issued after: at - * once unless writes still wait behind an owed import or a running write. */ + * once unless a write is running or writes wait in line. */ readInOrder(read: () => T): Promise { return this.queue.readInOrder(read) } diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts index 9b5653b060f..47764033180 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts @@ -1,5 +1,5 @@ -// A write has landed in the fold when its call returns, except during an owed import, when it lands -// in queue order. A write issued from inside a running write joins the line behind it, never nested. +// A write has landed in the fold when its call returns. A write issued from inside a running write +// joins the line behind it, never nested. // A read in the queue always settles: behind a write that failed, and refused once closed. import { mkdtemp, rm } from 'node:fs/promises' @@ -16,7 +16,6 @@ import { closeTestJournalHostDatabases, openTestJournalHostDatabase } from './journal-host-database-test-support' -import { openJournalOwingImport } from './journal-owed-import-test-support' import type { AgentSessionJournal } from './journal-store' import { openAgentSessionJournal } from './journal-store-factory' import { JournalWriteQueue } from './journal-write-queue' @@ -67,15 +66,6 @@ async function idleJournal(): Promise { return journal } -async function journalOwingImport(): Promise<{ journal: AgentSessionJournal; history: number }> { - const { journal, history } = await openJournalOwingImport({ - stateDirectory: root, - identity: IDENTITY - }) - opened.push(journal) - return { journal, history: history.length } -} - function sequenceOf(journal: AgentSessionJournal, ordinal: number): number | undefined { const key = agentJournalItemKey(item(ordinal)) let found: number | undefined @@ -101,25 +91,6 @@ describe('when a journal write lands', () => { await expect(Promise.all([first, second])).resolves.toHaveLength(2) }) - it('lands in queue order, behind the history it copies, while an import is owed', async () => { - const { journal, history } = await journalOwingImport() - - const first = journal.appendItem(item(1), reply('one'), OPTIONS) - const second = journal.appendItem(item(2), reply('two'), OPTIONS) - // Neither has landed: both wait behind the copy the first write pays. - expect(journal.itemBody(agentJournalItemKey(item(1)))).toBeNull() - expect(journal.importPending).toBe(true) - - await second - await first - expect(journal.importPending).toBe(false) - expect(sequenceOf(journal, 1)).toBe(history + 1) - expect(sequenceOf(journal, 2)).toBe(history + 2) - // The copy is paid: the next write lands at its call again. - journal.appendItem(item(3), reply('three'), OPTIONS).catch(() => undefined) - expect(sequenceOf(journal, 3)).toBe(history + 3) - }) - it('joins the line behind a write it was issued from inside, never nested in it', async () => { const journal = await idleJournal() let inner: Promise | null = null @@ -142,7 +113,7 @@ describe('when a journal write lands', () => { }) describe('the journal write queue', () => { - it('runs a write before it returns when nothing is owed or running', () => { + it('runs a write before it returns when nothing is running', () => { const queue = new JournalWriteQueue('session-1') const ran: string[] = [] void queue.serialize(() => { @@ -187,27 +158,6 @@ describe('the journal write queue', () => { await Promise.all([outer, nested, after]) expect(ran).toEqual(['outer', 'nested', 'after']) }) - - it('runs every write admitted while work is owed after it, in order', async () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - const owed = Promise.withResolvers() - queue.owe(async () => { - await owed.promise - ran.push('owed') - }) - const first = queue.serialize(() => { - ran.push('first') - }) - const read = queue.readInOrder(() => ran.push('read')) - owed.resolve() - const second = queue.serialize(() => { - ran.push('second') - }) - await Promise.all([first, read, second]) - expect(ran).toEqual(['owed', 'first', 'read', 'second']) - expect(queue.owing).toBe(false) - }) }) describe('a read in the journal write queue', () => { @@ -218,29 +168,17 @@ describe('a read in the journal write queue', () => { expect(ran).toEqual(['read']) }) - it('does not pay owed work, and runs behind a write that pays it', async () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - queue.owe(async () => { - ran.push('owed') - }) - void queue.readInOrder(() => ran.push('read before')) - expect(ran).toEqual(['read before']) - const write = queue.serialize(() => { - ran.push('write') - }) - const read = queue.readInOrder(() => ran.push('read after')) - await Promise.all([write, read]) - expect(ran).toEqual(['read before', 'owed', 'write', 'read after']) - }) - it('runs behind a write that failed', async () => { const queue = new JournalWriteQueue('session-1') - queue.owe(async () => undefined) - const failed = queue.serialize(() => { - throw new Error('disk I/O error') + let failed: Promise = Promise.resolve() + let read: Promise = Promise.resolve('') + // Issued from inside a running write, so both join the line. + await queue.serialize(() => { + failed = queue.serialize(() => { + throw new Error('disk I/O error') + }) + read = queue.readInOrder(() => 'read') }) - const read = queue.readInOrder(() => 'read') await expect(failed).rejects.toThrow('disk I/O error') await expect(read).resolves.toBe('read') }) diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index 74ef33472bd..92eb2dcd590 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -12,14 +12,13 @@ export type JournalWriteResult = T extends PromiseLike ? never : T export type JournalWriteBody = () => JournalWriteResult /** - * A write has landed in the fold when its call returns, except during an owed import, when it - * lands in queue order. + * A write has landed in the fold when its call returns, unless it was issued from inside another + * write or behind one still waiting in line. * - * A write finds the queue idle unless work is owed, a write is running, or writes wait in line; - * then it runs before `serialize` returns. Every write body is synchronous (`JournalWriteBody` - * refuses a promise), so it has committed by then. Otherwise it joins the line: behind the owed - * import it pays first, or behind the running write it was issued from, never nested inside it. - * Admission is checked at ENQUEUE and is permanent. + * A write finds the queue idle unless a write is running or writes wait in line; then it runs + * before `serialize` returns. Every write body is synchronous (`JournalWriteBody` refuses a + * promise), so it has committed by then. Otherwise it joins the line behind the writes ahead of + * it, never nested inside the running one. Admission is checked at ENQUEUE and is permanent. */ export class JournalWriteQueue { /** Settles once every write admitted so far has, whatever its outcome. */ @@ -28,8 +27,6 @@ export class JournalWriteQueue { private waiting = 0 private running = false private closed = false - /** Runs before the next write, and stays owed until it succeeds. */ - private owed: (() => Promise) | null = null constructor(private readonly sessionId: string) {} @@ -41,19 +38,17 @@ export class JournalWriteQueue { if (this.closed) { return Promise.reject(this.closedError()) } - return this.owed !== null || this.lineBusy - ? this.join(run, this.owed !== null) - : this.runNow(run) + return this.lineBusy ? this.join(run) : this.runNow(run) } /** Runs `read` after every write admitted before it, whatever each one's outcome, and ahead of any - * admitted after: at once when none waits. Owed work is not paid for a read. A closed queue - * refuses it, as it refuses a write: its fold may be replaced. */ + * admitted after: at once when none waits. A closed queue refuses it, as it refuses a write: its + * fold may be replaced. */ readInOrder(read: () => T): Promise { if (this.closed) { return Promise.reject(this.closedError()) } - return this.lineBusy ? this.join(read, false) : this.runNow(read) + return this.lineBusy ? this.join(read) : this.runNow(read) } private get lineBusy(): boolean { @@ -67,22 +62,6 @@ export class JournalWriteQueue { ) } - owe(work: () => Promise): void { - this.owed = work - } - - get owing(): boolean { - return this.owed !== null - } - - private payOwed = async (): Promise => { - const owed = this.owed - if (owed) { - await owed() - this.owed = null - } - } - /** Resolves once every write admitted so far has settled, whatever its outcome. */ drain(): Promise { return this.writes.then(() => undefined) @@ -107,8 +86,8 @@ export class JournalWriteQueue { return result } - private join(run: () => T, paysOwed: boolean): Promise { - const started = paysOwed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run) + private join(run: () => T): Promise { + const started = this.writes.then(run) this.writes = started.catch(() => undefined) this.waiting++ const settle = (): void => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts index 12ced4eacf8..b6cd948fa4d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-chat-stop.ts @@ -78,7 +78,7 @@ export function mutateWithChatStop( // Read before the withdrawal it decides on is issued. const hadQueued = ctx.journal.submissions().some(isQueuedAgentJournalSubmission) // Stop withdraws every queued SUBMISSION first, whatever the start or the child is doing. - // Issued, not awaited: the interrupt never waits on bookkeeping. (The open paid any owed import.) + // Issued, not awaited: the interrupt never waits on bookkeeping. const withdrew = withdrawQueuedForStop(ctx, () => ctx.journal.rejectQueuedSubmissions( ctx.fence, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 28393709a64..f60b2f29583 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -49,11 +49,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { const readRefusals = createJournalOpenReadRefusals( deferredStructuredAgentSessionLogger(() => deps().logger) ) - // The owed copy fails as an open does: the reader gets the classified refusal, never its text. - const whenImported = (sessionId: string, session: StructuredAgentSessionHostSession) => - session.journal.whenImported().catch((error: unknown) => { - throw readRefusals.refusal(sessionId, error) - }) const stopAgent = (sessionId: string, ending: StructuredAgentSessionStopEnding) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId, ending) @@ -118,11 +113,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { conversation: async (sessionId: string): Promise => { const open = sessions.get(sessionId) if (open) { - // Restore left its per-chat file uncopied; a reader gets the chat from the one database. - // Awaited only then: an open conversation otherwise answers in the same turn. - if (open.journal.importPending) { - await whenImported(sessionId, open) - } readRefusals.forget(sessionId) return open } @@ -145,10 +135,6 @@ export function createStructuredAgentSessionConversationLifetime(host: { reason: 'recordMissing' }) } - // Restore may have opened it while this waited. - if (session.journal.importPending) { - await whenImported(sessionId, session) - } readRefusals.forget(sessionId) return session }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index 9c556916a39..aa1a5c3d619 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -43,8 +43,6 @@ export type StructuredAgentSessionConversationOpenDeps = { * what the gone generation left running itself, from what it read before. */ export type StructuredAgentSessionConversationOpenOptions = { acquisition?: boolean - /** A restore's open, which copies no per-chat file: see `AgentSessionJournal.whenImported`. */ - deferPerSessionImport?: boolean } export type StructuredAgentSessionConversationOpenContext = { @@ -90,11 +88,7 @@ export async function openStructuredAgentSessionConversationJournal( expectedRuntimeFence: fence }) const identity = journalIdentityFor(record, params) - const journal = await openAgentSessionJournal({ - identity, - database: deps.journalDatabase, - deferPerSessionImport: options.deferPerSessionImport - }) + const journal = await openAgentSessionJournal({ identity, database: deps.journalDatabase }) try { // A handed-over row found here is only doubt, which provider history decides under a won lease. await journal.markPendingSubmissionsUnknown(fence) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts index 16f636af093..9a0637c99bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink-queue.ts @@ -214,7 +214,7 @@ export class StructuredAgentSessionSinkQueue { outcome = runNow(() => operation.run(bound)) } else { this.waitingPublications.set(key, operation) - // At handover, unless writes still wait behind an owed import; then at its place in line, so + // At handover, unless a write is running or writes wait in line; then at its place in it, so // it never announces ahead of the writes issued before it. outcome = runNow(() => bound.journal.readInOrder(() => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts index 5f9d2c216fe..476089292a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-event-sink.test.ts @@ -21,7 +21,6 @@ import { import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { testEventSinkLogging } from './structured-agent-session-logger-test-support' @@ -120,41 +119,6 @@ function target( } } -/** A real journal whose copy from an earlier build's per-chat file is still owed: its first write - * pays it, and every write issued meanwhile waits in its queue. The one real backlog a chat has. */ -async function owingTarget(fence: number) { - const root = await mkdtemp(join(tmpdir(), 'orca-event-sink-owed-')) - const { journal, history } = await openJournalOwingImport({ - stateDirectory: root, - identity: JOURNAL_IDENTITY - }) - const published: (AgentSessionTurnActivity | null | undefined)[] = [] - const bound: StructuredAgentSessionEventTarget = { - journal, - fence, - publish: (activity) => published.push(activity) - } - /** The row each of these ordinals created, in journal order. */ - const landed = (): number[] => - journal.snapshot().items.flatMap((item) => { - const ordinal = [0, 1, 2, 3, 4].find( - (index) => item.itemId === agentJournalItemKey(identity(index)) - ) - return ordinal === undefined ? [] : [ordinal] - }) - return { - bound, - journal, - history: history.length, - published, - landed, - dispose: async () => { - await journal.close() - await rm(root, { recursive: true, force: true }) - } - } -} - describe('deferred structured agent-session event sink', () => { it('buffers writes made before the journal exists and drains them in arrival order', async () => { const log: Recorded[] = [] @@ -261,29 +225,6 @@ describe('deferred structured agent-session event sink', () => { expect(log).toEqual([{ call: 'appendItem', fence: 1, ordinal: 0 }]) }) - it('lands writes already handed to the journal when closed; only never-bound ones drop', async () => { - const owed = await owingTarget(3) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind(owed.bound) - - deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.appendItem(identity(2), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - // Handed over, waiting behind the copy: none has landed when the sink closes. - expect(owed.landed()).toEqual([]) - const written = deferred.sink.written?.() - deferred.close() - expect( - deferred.sink.tryAppendItem?.(identity(3), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).toEqual({ accepted: false, reason: 'closed' }) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - - expect(owed.landed()).toEqual([0, 1, 2]) - await expect(written).resolves.toEqual({ ok: true }) - expect(owed.journal.cursor().sequence).toBe(owed.history + 3) - await owed.dispose() - }) - it('reports one refused append, fails the barrier, and stops later writes', async () => { const log: Recorded[] = [] const errors: unknown[] = [] @@ -376,47 +317,6 @@ describe('deferred structured agent-session event sink', () => { expect(log).toHaveLength(2) }) - it('pauses and resumes the reader at the same watermarks over writes the journal has not landed', async () => { - const changes: boolean[] = [] - const readingControl = { pauseReading: vi.fn(), resumeReading: vi.fn() } - const deferred = createDeferredStructuredAgentSessionEventSink({ - ...testEventSinkLogging(), - watermarks: { - pauseQueuedBytes: 1_000_000, - maxQueuedBytes: 1_000_000, - lowQueuedBytes: 1_000_000, - pauseQueuedOperations: 3, - maxQueuedOperations: 4, - lowQueuedOperations: 1 - }, - readingControl, - onBackpressureChange: (paused) => changes.push(paused) - }) - const owed = await owingTarget(5) - deferred.bind(owed.bound) - const append = (ordinal: number) => - deferred.sink.tryAppendItem?.(identity(ordinal), BODY, { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - - expect([append(0), append(1)]).toEqual([{ accepted: true }, { accepted: true }]) - expect(readingControl.pauseReading).not.toHaveBeenCalled() - expect(append(2)).toEqual({ accepted: true }) - expect(deferred.state()).toMatchObject({ backpressured: true, queuedOperations: 3 }) - expect(readingControl.pauseReading).toHaveBeenCalledOnce() - expect(append(3)).toEqual({ accepted: true }) - expect(append(4)).toEqual({ accepted: false, reason: 'backpressure' }) - - // Nothing has landed yet: every admitted write waits behind the copy it owes. - expect(owed.landed()).toEqual([]) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect(changes).toEqual([true, false]) - expect(readingControl.resumeReading).toHaveBeenCalledOnce() - expect(deferred.state()).toMatchObject({ backpressured: false, queuedOperations: 0 }) - expect(owed.landed()).toEqual([0, 1, 2, 3]) - await owed.dispose() - }) - it('admits a resolved append and publication as one bounded operation', async () => { const log: Recorded[] = [] const deferred = createDeferredStructuredAgentSessionEventSink({ @@ -654,28 +554,6 @@ describe('deferred structured agent-session event sink', () => { } ]) }) - - it('skips a publication handed over and then replaced, and runs its replacement after the write between them', async () => { - const owed = await owingTarget(6) - const deferred = createDeferredStructuredAgentSessionEventSink(testEventSinkLogging()) - deferred.bind(owed.bound) - const seen: string[] = [] - owed.bound.publish = (activity) => - seen.push(`${activity?.text ?? 'none'} over ${owed.landed().join(',')}`) - - deferred.sink.appendItem(identity(0), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Thinking' }) - deferred.sink.appendItem(identity(1), BODY, { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - deferred.sink.setActivity?.({ turnId: 'turn-1', text: 'Checking the result' }) - // The first publication was handed over and waits behind the copy; the second replaced it. - expect(deferred.state().queuedOperations).toBe(3) - expect(seen).toEqual([]) - - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - expect(seen).toEqual(['Checking the result over 0,1']) - expect(deferred.state().queuedOperations).toBe(0) - await owed.dispose() - }) }) describe('producer linkage reaches the journal through every append path', () => { 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 835f9f9e204..e0f193ba0b4 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 @@ -229,10 +229,8 @@ export class StructuredAgentSessionHost { showSessionTabs = this.tabs.showSessionTabs setSessionTabVisibility = this.tabs.setSessionTabVisibility notifySessionTabHidden = this.tabs.notifySessionTabHidden - /** The records file could not be read this launch, so chats it holds are not listed yet. */ - legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true - /** This runtime holds a chat: a record, or the records file's chats still owed their copy. */ - holdsSessions = (): boolean => this.deps.store.holdsRecords() || this.legacyRecordImportOwed() + /** This runtime holds a chat record, readable or not. */ + holdsSessions = (): boolean => this.deps.store.holdsRecords() onSessionsHeld = (listener: () => void): (() => void) => this.deps.store.onFirstRecord(listener) reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts deleted file mode 100644 index a8f45664a1d..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -// A chat whose per-chat file did not read back as its copy is refused as history that cannot be -// loaded: on a send and a Stop as on a read. Each retry runs the same copy, so "try again" is wrong. - -import { cp, rm } from 'node:fs/promises' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { - JournalImportMismatchError, - journalOpenRefusalError -} from '../agent-session-journal/journal-open-failure' -import { replayJournal } from '../agent-session-journal/journal-open' -import type * as PerSessionImport from '../agent-session-journal/journal-per-session-import' -import { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - adapter, - attach, - CALLER, - envelope, - hostTestState, - replaceHostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestMessage -} from './structured-agent-session-host-test-data' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' - -const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) - -vi.mock('../agent-session-journal/journal-per-session-import', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - // What the copy's verify throws for a file that does not read back as copied. - importPerSessionJournal: async ( - input: Parameters[0] - ) => { - if (!copy.mismatched) { - return actual.importPerSessionJournal(input) - } - throw journalOpenRefusalError( - new JournalImportMismatchError( - `per-chat journal of ${input.identity.sessionId} read back short` - ) - ) - }, - // Restore lists a chat still in its per-chat file from a fold of it, and owes the copy. - previewPerSessionJournal: async ( - input: Parameters[0] - ) => - copy.mismatched - ? replayJournal(input.database.db, input.identity.sessionId) - : actual.previewPerSessionJournal(input) - } -}) - -const UNLOADABLE = { - ok: false, - refusal: { - code: 'agent_session_journal_unreadable', - message: 'Unable to load this chat.', - details: { reason: 'journalCorrupt' } - } -} - -const relaunchedRoots: string[] = [] - -beforeEach(() => { - copy.mismatched = false - vi.spyOn(console, 'warn').mockImplementation(() => undefined) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await Promise.all( - relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) - ) -}) - -/** A restarted process over the chat's files, holding no chat open; its copy now mismatches. */ -async function relaunchWithMismatchedCopy(): Promise { - const before = hostTestState() - await attach() - await before.host.flushStreamedEvents(SESSION) - await before.store.renewLeases([]) - const relaunched = `${before.root}-relaunched` - relaunchedRoots.push(relaunched) - // A dead process holds no lock. - await cp(before.root, relaunched, { - recursive: true, - filter: (source) => !source.includes('.lock') - }) - const store = await openTestAgentSessionRecordStore(relaunched) - const host = new StructuredAgentSessionHost({ - agents: NO_STRUCTURED_AGENTS, - logger: createStructuredAgentSessionLogger(), - store, - adapter: adapter(), - journalDatabase: openTestJournalHostDatabase(relaunched), - claimKeyId: 'key-1', - mintSpawnToken: () => 'spawn-next', - now: () => NOW - }) - replaceHostTestState({ store, host }) - copy.mismatched = true - return host -} - -function send(host: StructuredAgentSessionHost) { - const body = hostTestMessage('sent to a chat that did not copy') - return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) -} - -it('refuses a send and a Stop that open the chat as unloadable', async () => { - const host = await relaunchWithMismatchedCopy() - - await expect(send(host)).resolves.toMatchObject(UNLOADABLE) - await expect( - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - }) - ).resolves.toMatchObject(UNLOADABLE) - await host.flushAllStreamedEvents() -}) - -// Startup listed the chat from its per-chat file; the send's write pays the copy it owes. -it('refuses a send to a restored chat as unloadable when the copy it owes does not verify', async () => { - const host = await relaunchWithMismatchedCopy() - await host.restoreReadableSessions([SESSION]) - expect(host.hasSession(SESSION)).toBe(true) - - await expect(send(host)).resolves.toMatchObject(UNLOADABLE) - await host.flushAllStreamedEvents() -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts deleted file mode 100644 index b995b1a373f..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-owed-import.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// A mutation reads the fold after every write issued before it, even while a restore's import is -// owed: the open pays the import first, so provider rows queued behind it have landed. A failed -// import is reported and never refuses the mutation. - -import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' -import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import type { StructuredAgentSessionHost } from './structured-agent-session-host' -import { - attach, - CALLER, - envelope, - hostTestState -} from './structured-agent-session-host-test-harness' -import { - HOST_TEST_SESSION as SESSION, - HOST_TEST_THREAD as THREAD -} from './structured-agent-session-host-test-data' -import { CODEX_STRUCTURED_AGENT } from '../../codex/codex-structured-agent-definition' - -let host: StructuredAgentSessionHost -let acquire: Mock -let cancelTurn: Mock -let warned: ReturnType - -beforeEach(() => { - ;({ host, acquire, cancelTurn } = hostTestState()) - warned = vi.spyOn(console, 'warn').mockImplementation(() => undefined) -}) - -afterEach(() => { - vi.restoreAllMocks() -}) - -function journal(): AgentSessionJournal { - const open = host.collaboratorsForTests().sessions.get(SESSION)?.journal - if (!open) { - throw new Error('no open journal') - } - return open -} - -function providerEvents(): StructuredAgentSessionEventSink { - const events = acquire.mock.calls.at(-1)?.[0].events - if (!events) { - throw new Error('no provider bound') - } - return events -} - -/** Owed work standing in for a restore's import: every write waits behind it until it settles. */ -function oweImport(): PromiseWithResolvers { - const owed = Promise.withResolvers() - journal()['queue'].owe(() => owed.promise) - return owed -} - -/** A turn the provider opened by itself, its row queued behind the owed import. */ -function providerOpensTurn(turnId: string, ordinal: number): void { - providerEvents().appendItem( - { provider: 'codex', threadId: THREAD, turnId, ordinal }, - { kind: 'turn', turnId, state: 'running' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) -} - -function stopNamingNoTurn() { - return host.cancel(CALLER, { envelope: envelope('agentSession.cancel', {}) }) -} - -describe('a mutation while an import is owed', () => { - it('a Stop naming no turn sees the turn the provider opened, and interrupts it', async () => { - await attach() - const owed = oweImport() - providerOpensTurn('turn-p', 900) - expect(journal().activeTurnId()).toBeNull() - - const stopping = stopNamingNoTurn() - await new Promise((resolve) => setTimeout(resolve, 50)) - // It waits for the import, as a reader does, then reads the turn. - expect(cancelTurn).not.toHaveBeenCalled() - owed.resolve() - - expect(await stopping).toMatchObject({ ok: true, value: { cancelled: true } }) - expect(cancelTurn).toHaveBeenCalledOnce() - }) - - it('a goal set sees the goal and the turn the provider wrote, and replaces it in that turn', async () => { - await attach() - const changeThreadGoal = vi.fn(async () => ({ ok: true as const })) - Object.assign(host.deps.adapter, { - changeThreadGoal, - capabilities: () => CODEX_STRUCTURED_AGENT.capabilities - }) - const owed = oweImport() - providerOpensTurn('turn-g', 901) - providerEvents().appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-g', ordinal: 902 }, - { - kind: 'status', - text: 'Goal', - threadGoal: { - state: 'set', - goal: { - objective: 'Earlier goal', - status: 'active', - tokenBudget: null, - tokensUsed: 1, - timeUsedSeconds: 2, - createdAt: 3_000, - updatedAt: 4_000 - } - } - }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - const change = { kind: 'set' as const, objective: 'New goal' } - const setting = host.changeThreadGoal(CALLER, { - envelope: envelope('agentSession.threadGoal', { change }), - change - }) - // Long enough for the goal set to reach its read before the import lands. - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(changeThreadGoal).not.toHaveBeenCalled() - owed.resolve() - - expect(await setting).toMatchObject({ ok: true }) - expect(changeThreadGoal).toHaveBeenCalledWith(expect.objectContaining({ replacesGoal: true })) - const objective = journal() - .snapshot() - .items.find((item) => item.body.kind === 'message' && item.body.sentAs === 'goal') - expect(objective?.turnScope).toEqual({ - kind: 'turn', - turnItemId: expect.stringContaining(':turn-g:') - }) - }) - - it('a failed import is reported and the Stop still answers', async () => { - await attach() - const owed = oweImport() - owed.reject(new Error('disk I/O error')) - - expect(await stopNamingNoTurn()).toMatchObject({ ok: true }) - expect(warned).toHaveBeenCalledWith( - '[agent-session] open-for-write: the import owed before a write failed', - expect.objectContaining({ error: new Error('disk I/O error') }) - ) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 7b9b7401037..d55f7cea3fb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -516,50 +516,4 @@ describe('a failed Stop', () => { } await expectPaused(draftId) }) - - // The drain shares the Stop's lane, so a failed Stop must hold it until its pause lands. - it('keeps its pause when it fails while its writes wait behind owed work', async () => { - const turn = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-w', ordinal: 900 } - const turnRow = (state: 'running' | 'completed') => { - rig - .providerEvents() - .appendItem( - turn, - { kind: 'turn', turnId: 'turn-w', state, startedAt: 1 }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - const working = await rig.workingSend() - turnRow('running') - await rig.settleAccepted(working, 'w') - const draftId = await queuedDraft('paused by stop') - // The interrupt ends the turn, so the drain runs as soon as the lane frees. - vi.mocked(rig.host.deps.adapter.cancelTurn).mockImplementationOnce(async () => { - turnRow('completed') - return { cancelled: true } - }) - const journal = rig.host.collaboratorsForTests().sessions.get(HOST_TEST_SESSION)?.journal - if (!journal) { - throw new Error('no open journal') - } - const append = journal.appendItem.bind(journal) - vi.spyOn(journal, 'appendItem').mockImplementation((identity, body, options) => - body.kind === 'status' - ? Promise.reject(new Error('disk full')) - : append(identity, body, options) - ) - // Owed from the Stop's first write, after the open paid any import: its writes wait behind it. - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - const stopping = rig.stop() - await eventually(() => expect(rig.host.deps.adapter.cancelTurn).toHaveBeenCalledOnce()) - owed.resolve() - await expect(stopping).rejects.toThrow('disk full') - expect(journal.activeTurnId()).toBeNull() - await expectPaused(draftId) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 38b2453c3bf..be330810097 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -1,22 +1,18 @@ -// Read restore decides whether a session comes back at all. -// -// A chat still in the pre-SQLite format has no `journal.db`, so the probe that -// loads one reports nothing. Reading that as "no session" is what removed these -// chats: an unpublished session is also what prunes its tab out of the saved -// workspace, so the tab is gone before anything can explain itself. +// Read restore decides whether a session comes back at all: only one with a record and a journal +// to read is published, and publishing it starts no agent. -import { mkdir, mkdtemp, 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' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { closeTestJournalHostDatabases, openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { publishJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' @@ -62,16 +58,6 @@ const openDeps = () => ({ }) const opened: AgentSessionJournal[] = [] -async function writeRemnant(name: string): Promise { - const dir = journalDirectoryFor(journalRoot, { - workspaceId: WORKSPACE_ID, - sessionId: SESSION_ID - }) - await mkdir(dir, { recursive: true }) - await writeFile(join(dir, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') - return join(dir, name) -} - beforeEach(async () => { journalRoot = await mkdtemp(join(tmpdir(), 'orca-read-restore-')) }) @@ -82,40 +68,31 @@ afterEach(async () => { await rm(journalRoot, { recursive: true, force: true }) }) -describe('a session whose journal is still the pre-SQLite format', () => { - it('is published, carrying the message that explains it', async () => { - const transcript = await writeRemnant('log.jsonl') +describe('read restore', () => { + it('publishes a session with a journal, starting no agent', async () => { + const deps = openDeps() + const restored = await restoreStructuredAgentSessionRead(deps, SESSION_ID) + expect(restored).toBeNull() + publishJournalSessionEpoch( + deps.journalDatabase.db, + { sessionId: SESSION_ID, workspaceId: WORKSPACE_ID }, + 'epoch-1' + ) - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) + const published = await restoreStructuredAgentSessionRead(deps, SESSION_ID) - expect(restored).not.toBeNull() - opened.push(restored!.session.journal) - const disclosed = restored!.session.journal - .snapshot() - .items.map((entry) => (entry.body.kind === 'status' ? entry.body.text : '')) - expect(disclosed.join('')).toContain(transcript) - // Publishing it costs no agent process; acquisition still waits for the user. - expect(restored!.session.child).toBeNull() + expect(published).not.toBeNull() + opened.push(published!.session.journal) + expect(published!.session.child).toBeNull() }) - it('is published for a remnant whose log is gone', async () => { - await writeRemnant('snapshot.json') - - const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) - - expect(restored).not.toBeNull() - opened.push(restored!.session.journal) - }) - - it('still drops a session with neither a journal nor a remnant', async () => { + it('drops a session with no journal', async () => { const restored = await restoreStructuredAgentSessionRead(openDeps(), SESSION_ID) expect(restored).toBeNull() }) - it('still drops a session with no record', async () => { - await writeRemnant('log.jsonl') - + it('drops a session with no record', async () => { const restored = await restoreStructuredAgentSessionRead(openDeps(), 'unknown-session') expect(restored).toBeNull() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index cf80396d820..8c19df9c628 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,6 +1,3 @@ -import { existsSync } from 'node:fs' -import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' -import { legacyJournalDatabaseFile } from '../agent-session-journal/journal-paths' import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { openStructuredAgentSessionConversationJournal, @@ -11,7 +8,7 @@ import { /** * A reader's open: the conversation's own open, for a session that has a journal to read. One * with none — never written, or gone — stays unpublished rather than founding an empty one. - * Opening can still write: the crash boundary, and the row explaining an old-format history. + * Opening can still write: the crash boundary. */ export async function restoreStructuredAgentSessionRead( deps: StructuredAgentSessionConversationOpenDeps, @@ -21,22 +18,8 @@ export async function restoreStructuredAgentSessionRead( if (!record) { return null } - const database = deps.journalDatabase - if (readJournalSessionEpoch(database.db, sessionId) === null) { - // Not in the host's database yet: its history may still sit in a per-chat file the open - // imports, or in the pre-SQLite format the open explains. - const legacyDirectory = database.legacyDirectoryFor({ - workspaceId: record.location.workspaceId, - sessionId - }) - if ( - !existsSync(legacyJournalDatabaseFile(legacyDirectory)) && - !findJournalFileFormatRemnant(legacyDirectory) - ) { - return null - } + if (readJournalSessionEpoch(deps.journalDatabase.db, sessionId) === null) { + return null } - return openStructuredAgentSessionConversationJournal(deps, record, { - deferPerSessionImport: true - }) + return openStructuredAgentSessionConversationJournal(deps, record) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 5ff32f09e3c..55489d66c26 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -32,6 +32,7 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-ho import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -352,12 +353,9 @@ describe('agentSessionRefusalOperationState host oracle', () => { const unreadable = await createHarness() await unreadable.host.close(SESSION, 'evict') - // The journal's open asks where the chat's per-chat file lives before it reads anything. const unreadableOpen = vi - .spyOn(unreadable.host.deps.journalDatabase, 'legacyDirectoryFor') - .mockImplementation(() => { - throw new Error('journal path unreadable') - }) + .spyOn(AgentSessionJournal.prototype, 'open') + .mockRejectedValue(new Error('journal path unreadable')) const unreadableSend = { method: 'agentSession.send' as const, operationId: operationId() } record( await assertHostAgreement( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts index c14899af112..c732a04b458 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resolved-append.test.ts @@ -9,7 +9,11 @@ import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventTarget, @@ -37,10 +41,8 @@ let journal: AgentSessionJournal beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-resolved-append-')) - // Its copy owed, so every write handed over waits in the queue: a resolver that read at submit - // would read the row before the revisions ahead of it had landed. - ;({ journal } = await openJournalOwingImport({ - stateDirectory: root, + journal = await openAgentSessionJournal({ + database: openTestJournalHostDatabase(root), identity: { sessionId: 'session-1', workspaceId: 'workspace-1', @@ -48,11 +50,12 @@ beforeEach(async () => { agent: 'codex', providerHandle: codexProviderHandle('thread-1') } - })) + }) }) afterEach(async () => { await journal.close() + closeTestJournalHostDatabases() await rm(root, { recursive: true, force: true }) }) @@ -90,7 +93,9 @@ describe('resolved revisions', () => { } // Three revisions of one row stay three operations; none replaces another. expect(deferred.state().queuedOperations).toBe(3) - deferred.bind(journalTarget()) + // Bound from inside a running read, so every revision waits in line: one resolved at + // handover would read the row before the revisions ahead of it had landed. + await journal.readInOrder(() => deferred.bind(journalTarget())) await expect(deferred.drained()).resolves.toEqual({ ok: true }) expect(rowText()).toBe('abc') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts deleted file mode 100644 index 0cf019e7239..00000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ /dev/null @@ -1,610 +0,0 @@ -// Startup restore lists a chat that is still in its per-chat file from a read-only fold of that -// file, and copies nothing: the copy waits for the chat's first real read or write, which is -// restore's own only for a chat the last run left mid-work. Restore stays the cost it was when -// every chat had its own file, and opens no file it does not restore. - -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { QUEUED_MESSAGE_PAUSED_KEPT } from '../../../shared/agent-session-queued-message-wire' -import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -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 { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' -import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' -import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import Database from '../../sqlite/sync-database' -import type * as SyncDatabaseModule from '../../sqlite/sync-database' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase, - readTestJournalRows -} from '../agent-session-journal/journal-host-database-test-support' -import { - journalDirectoryFor, - legacyJournalDatabaseFile -} from '../agent-session-journal/journal-paths' -import { - readJournalSessionEpoch, - type JournalStoredRow -} from '../agent-session-journal/journal-row-table' -import { previewPerSessionJournal } from '../agent-session-journal/journal-per-session-import' -import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { journalIdentityFor } from './structured-agent-session-attach' -import { attachParamsForRecord } from './structured-agent-session-conversation-open' -import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' -import { createStructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' -import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' -import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' -import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' -import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' -import { recordingStructuredAgentSessionLogger } from './structured-agent-session-logger-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { USER_MESSAGE_SOURCE } from '../../../shared/agent-session-message-source' - -const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ - readOnlyOpens: new Array(), - openReadOnly: new Set() -})) - -// Every read-only open is of a per-chat file: the host's own database opens read-write. -vi.mock('../../sqlite/sync-database', async (importOriginal) => { - const actual = await importOriginal() - class RecordingDatabase extends actual.default { - constructor(...args: ConstructorParameters) { - super(...args) - if (args[1]?.readonly) { - readOnlyOpens.push(String(args[0])) - openReadOnly.add(this) - } - } - - override close(): void { - openReadOnly.delete(this) - super.close() - } - } - return { ...actual, default: RecordingDatabase } -}) - -const WORKSPACE_ID = 'repo-1::/tmp/workspace' -const PROMPT = 'add a retry' - -let root: string -let clock = 1_000 - -function recordFor(sessionId: string): AgentSessionRecord { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore reads only the id, location, provider, handle chain, account home and lease. - return { - schemaVersion: 2, - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: WORKSPACE_ID, - workspaceKind: 'git-worktree' - }, - provider: 'codex', - providerHandleChain: [ - { - linkId: `codex-1-${sessionId}`, - handle: codexProviderHandle(`thread-${sessionId}`), - origin: 'created', - mintedAtFence: 1, - observedAt: 1 - } - ], - accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, - createdAt: 1, - updatedAt: 2, - lease: { sessionId, runtimeKind: 'native', runtimeFence: 1 } - } as unknown as AgentSessionRecord -} - -function identityFor(sessionId: string) { - const record = recordFor(sessionId) - return journalIdentityFor( - record, - attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: 1 }) - ) -} - -function legacyDirFor(sessionId: string): string { - return journalDirectoryFor(root, { workspaceId: WORKSPACE_ID, sessionId }) -} - -/** What the run before the upgrade left open in a chat, for its restore to settle. */ -type MidWork = 'running tool call' | 'unresolved send' | 'never handed over' - -/** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ -async function seedLegacyChat( - sessionId: string, - replies = 1, - midWork?: MidWork -): Promise { - const scratch = join(root, `scratch-${sessionId}`) - const identity = identityFor(sessionId) - const journal = await openAgentSessionJournal({ - identity, - database: openTestJournalHostDatabase(scratch), - now: () => (clock += 1) - }) - await journal.appendSubmission({ - clientMessageId: `client-${sessionId}`, - payloadFingerprint: 'fp-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, - fence: 1, - handoverRecorded: true, - // A person's send that run accepted and quit before handing over. - ...(midWork === 'never handed over' - ? { origin: 'client' as const, source: USER_MESSAGE_SOURCE } - : {}) - }) - if (midWork === 'running tool call') { - await journal.appendItem( - { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal: 50 }, - { kind: 'tool-call', name: 'Read', input: {}, state: 'running' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - if (midWork === 'never handed over') { - await journal.close() - return writeLegacyChat(sessionId, scratch, journal.epoch) - } - await journal.resolveDispatch( - midWork === 'unresolved send' - ? // Handed over, and never answered. - { - clientMessageId: `client-${sessionId}`, - fence: 1, - state: 'pending', - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - : { - clientMessageId: `client-${sessionId}`, - fence: 1, - state: 'accepted', - providerIdentity: { - provider: 'codex', - threadId: `thread-${sessionId}`, - turnId: 't', - ordinal: 0 - } - } - ) - for (let ordinal = 1; ordinal <= replies; ordinal += 1) { - await journal.appendItem( - { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `reply ${ordinal}` }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - await journal.close() - return writeLegacyChat(sessionId, scratch, journal.epoch) -} - -/** Moves the rows a scratch host wrote into the chat's own per-chat file, as an older build kept it. */ -async function writeLegacyChat( - sessionId: string, - scratch: string, - epoch: string -): Promise { - const rows = readTestJournalRows(openTestJournalHostDatabase(scratch).db, sessionId, epoch) - const path = legacyJournalDatabaseFile(legacyDirFor(sessionId)) - await mkdir(dirname(path), { recursive: true }) - const db = new Database(path) - try { - db.exec(` -CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, - ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) - db.pragma('user_version = 2') - const insert = db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - for (const row of rows) { - insert.run(sessionId, row.epoch, row.seq, row.ts, row.rowJson) - } - db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(sessionId, rows[0]!.epoch, 1) - } finally { - db.close() - } - return rows -} - -function legacyFile(sessionId: string): string { - return legacyJournalDatabaseFile(legacyDirFor(sessionId)) -} - -function hostDb(): Database.Database { - return openTestJournalHostDatabase(root).db -} - -function importCount(): number { - return Number(hostDb().prepare('SELECT count(*) AS n FROM journal_imports').get()?.n) -} - -type LifetimeHost = Parameters[0] - -function conversations(): StructuredAgentSessionConversations { - return new StructuredAgentSessionConversations({ - deliver: () => undefined, - logger: recordingStructuredAgentSessionLogger().logger, - now: () => clock - }) -} - -async function restore(sessionIds: readonly string[]) { - const sessions = conversations() - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and an open conversation's read touch only `getRecord` and `listRecords`. - const store = { - getRecord: (sessionId: string) => recordFor(sessionId), - listRecords: () => sessionIds.map(recordFor) - } as unknown as AgentSessionRecordStore - const log = recordingStructuredAgentSessionLogger() - const deps = { - store, - adapter: {}, - journalDatabase: openTestJournalHostDatabase(root), - logger: log.logger - } - await restoreStructuredAgentSessionsOnRestart({ - openDeps: deps, - records: sessionIds.map(recordFor), - reconcile: async () => true, - resolveRecovery: async () => true, - serialize: async (_sessionId, task) => task(), - hasSession: (sessionId) => sessions.has(sessionId), - onReadable: (sessionId, opened) => { - sessions.set(sessionId, opened.session) - } - }) - const context = (): StructuredAgentSessionLifetimeContext => - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reaching an open conversation reads only `deps.store` and `deps.adapter`. - ({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext - const lifetimeOver = (listed: LifetimeHost['sessions'], open: LifetimeHost['open']) => - createStructuredAgentSessionConversationLifetime({ - context, - sessions: listed, - serialize: async (_sessionId, task) => task(), - open, - deliveryActive: () => false, - closeStatus: () => undefined, - readChildWork: () => undefined - }) - const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) - return { sessions, lifetime, lifetimeOver, log } -} - -function texts(items: readonly { body: unknown }[]): string { - return JSON.stringify(items.map((entry) => entry.body)) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-restore-without-import-')) - clock = 1_000 - readOnlyOpens.length = 0 -}) - -afterEach(async () => { - vi.restoreAllMocks() - closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -describe('startup restore of chats still in their per-chat files', () => { - it('lists every restored chat without copying one, and opens only their files', async () => { - const restored = ['chat-a', 'chat-b', 'chat-c'] - const rows = new Map() - for (const sessionId of [...restored, 'chat-closed']) { - rows.set(sessionId, await seedLegacyChat(sessionId)) - } - readOnlyOpens.length = 0 - const before = new Map( - await Promise.all( - restored.map( - async (sessionId) => [sessionId, await readFile(legacyFile(sessionId))] as const - ) - ) - ) - - const { sessions } = await restore(restored) - - // Each file was closed by the fold that read it, so nothing holds it open for a later rename. - expect(openReadOnly.size).toBe(0) - // Read, never written: the same bytes, in the same place, beside only SQLite's own WAL files. - for (const sessionId of restored) { - expect((await readFile(legacyFile(sessionId))).equals(before.get(sessionId)!)).toBe(true) - const entries = await readdir(legacyDirFor(sessionId)) - expect(entries.filter((name) => !/^journal\.db(-wal|-shm)?$/.test(name))).toEqual([]) - } - - expect([...sessions.keys()].sort()).toEqual(restored) - for (const sessionId of restored) { - const journal = sessions.get(sessionId)!.journal - expect(journal.cursor().sequence).toBe(rows.get(sessionId)!.length) - expect(texts(journal.snapshot().items)).toContain('reply 1') - expect(readJournalSessionEpoch(hostDb(), sessionId)).toBeNull() - expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true) - } - for (const sessionId of restored) { - await sessions.get(sessionId)!.journal.close() - } - expect(importCount()).toBe(0) - // One read of each restored chat's file, and none of the chat that was not restored. - expect(readOnlyOpens.sort()).toEqual( - restored.map((sessionId) => legacyJournalDatabaseFile(legacyDirFor(sessionId))).sort() - ) - }) - - // Restore copies a chat only to write to it itself, settling what the last run left open (a - // turn, tool call, approval, question, send or subagent). A settled chat is never copied here. - it.each(['running tool call', 'unresolved send', 'never handed over'] as const)( - 'copies during restore only a chat it settles (%s)', - async (midWork) => { - const rows = await seedLegacyChat('chat-mid-work', 1, midWork) - await seedLegacyChat('chat-settled') - - const { sessions } = await restore(['chat-mid-work', 'chat-settled']) - - // Restore wrote a settlement to the chat left mid-work, so it copied that chat first. - const settled = sessions.get('chat-mid-work')!.journal - expect(settled.cursor().sequence).toBeGreaterThan(rows.length) - expect( - readTestJournalRows(hostDb(), 'chat-mid-work', rows[0]!.epoch).slice(0, rows.length) - ).toEqual(rows) - expect(existsSync(legacyFile('chat-mid-work'))).toBe(false) - expect(readJournalSessionEpoch(hostDb(), 'chat-settled')).toBeNull() - expect(existsSync(legacyFile('chat-settled'))).toBe(true) - expect(importCount()).toBe(1) - } - ) - - // A send the last run never handed over is kept as a card in the same database the copy wrote, - // after the copy: the card and the rejected send both land behind the chat's own rows. - it('keeps a send the last run never handed over as a card, after the copy', async () => { - const rows = await seedLegacyChat('chat-kept', 0, 'never handed over') - - const { sessions } = await restore(['chat-kept']) - - const journal = sessions.get('chat-kept')!.journal - await journal.whenImported() - const copied = readTestJournalRows(hostDb(), 'chat-kept', rows[0]!.epoch) - expect(copied.slice(0, rows.length)).toEqual(rows) - expect(copied).toHaveLength(rows.length + 1) - expect(JSON.parse(copied.at(-1)!.rowJson)).toMatchObject({ - kind: 'dispatch', - clientMessageId: 'client-chat-kept', - state: 'rejected' - }) - const cards = hostDb() - .prepare('SELECT message_id, hold_reason, state FROM queued_messages WHERE session_id = ?') - .all('chat-kept') - expect(cards).toEqual([ - { message_id: 'client-chat-kept', hold_reason: QUEUED_MESSAGE_PAUSED_KEPT, state: 'waiting' } - ]) - }) - - it('lets other work run while it reads a large per-chat file', async () => { - // Past one batch of the file's rows. - const rows = await seedLegacyChat('chat-a', 520) - let turns = 0 - let ticking = true - const tick = (): void => { - turns += 1 - if (ticking) { - setImmediate(tick) - } - } - - setImmediate(tick) - const folded = await previewPerSessionJournal({ - database: openTestJournalHostDatabase(root), - identity: identityFor('chat-a'), - legacyDirectory: legacyDirFor('chat-a') - }) - ticking = false - - expect(turns).toBeGreaterThan(0) - expect(folded?.state.lastSequence).toBe(rows.length) - expect(openReadOnly.size).toBe(0) - }) - - // T-B3 for a chat restore did not copy: the offer taken before the upgrade still stands. - it('offers the restorable turn of a chat it did not copy', async () => { - const rows = await seedLegacyChat('chat-a') - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. - const marker = { - sessionId: 'chat-a', - journalCursor: { epoch: rows[0]!.epoch, sequence: rows.length } - } as AgentSessionResumeMarker - - const { sessions } = await restore(['chat-a']) - const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ - logger: createStructuredAgentSessionLogger(), - sessions, - now: () => clock, - enqueue: (operation) => operation() - }) - - expect(withdrawal.movedOn(marker)).toBe(false) - expect( - latestStructuredAgentSessionPrompt(sessions.get('chat-a')!.journal.snapshot().items) - ).toBe(PROMPT) - expect(importCount()).toBe(0) - }) - - it('copies a chat on its first read, and serves its history verbatim', async () => { - const rows = await seedLegacyChat('chat-a') - const { lifetime, sessions } = await restore(['chat-a']) - const listed = sessions.get('chat-a')!.journal.snapshot() - - const journal = (await lifetime.conversation('chat-a')).journal - const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) - - // The copied chat is the chat restore listed, item for item. - expect(journal.snapshot()).toEqual(listed) - - expect(importCount()).toBe(1) - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) - expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) - expect(existsSync(legacyDirFor('chat-a'))).toBe(false) - }) - - // A read queued behind restore's open of the same chat reaches it through its own open, not the - // listing: it still waits for the copy, and reads the chat from the one database. - it('makes a read whose open lands on the chat restore opened wait for its copy', async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions, lifetimeOver } = await restore(['chat-a']) - const restored = sessions.get('chat-a')! - const lifetime = lifetimeOver(conversations(), async () => restored) - - const { journal } = await lifetime.conversation('chat-a') - const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) - - expect(importCount()).toBe(1) - expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) - }) - - // Whether the read finds the chat restore listed, or opens it and lands on the one restore opened. - it.each(['listed', 'opened'] as const)( - 'refuses a read of the chat restore %s when its copy meets damage, never with the storage text', - async (reach) => { - await seedLegacyChat('chat-a') - const { sessions, lifetime, lifetimeOver, log } = await restore(['chat-a']) - const restored = sessions.get('chat-a')! - await rm(legacyDirFor('chat-a'), { recursive: true, force: true }) - await mkdir(legacyDirFor('chat-a'), { recursive: true }) - await writeFile(legacyFile('chat-a'), 'not a database, and never was one') - const reader = - reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored) - - const read = reader.conversation('chat-a') - - await expect(read).rejects.toBeInstanceOf(AgentSessionRefusalError) - await expect(read).rejects.toMatchObject({ - message: 'agent_session_journal_unreadable', - refusal: { details: { reason: 'journalCorrupt' } } - }) - // The storage text the reader never sees goes to the host's log. - expect(log.entries.filter((entry) => entry.fields.scope === 'open-for-read')).toEqual([ - expect.objectContaining({ - fields: { scope: 'open-for-read', sessionId: 'chat-a', error: expect.any(Error) } - }) - ]) - } - ) - - it('copies a chat before its first write, and the write lands after its history', async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - - await journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - const stored = readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch) - expect(stored.slice(0, rows.length)).toEqual(rows) - expect(stored).toHaveLength(rows.length + 1) - expect(importCount()).toBe(1) - }) - - it("copies a chat before a Stop's event, which lands before the turn's end and the kill after it", async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - expect(journal.importPending).toBe(true) - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - - // In the Stop's order: its event, then the stopped turn's end, then a row the kill writes. - const [stopped, ended, killed] = await Promise.all([ - journal.appendStopEvent({ reason: 'user-stop' }, 1), - journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, - { kind: 'turn', turnId: 't', state: 'interrupted', startedAt: 1 }, - scope - ), - journal.appendItem( - { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 10 }, - { kind: 'status', text: 'the agent ended' }, - scope - ) - ]) - - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch).slice(0, rows.length)).toEqual( - rows - ) - expect([stopped.sequence, ended.cursor.sequence, killed.cursor.sequence]).toEqual([ - rows.length + 1, - rows.length + 2, - rows.length + 3 - ]) - }) - - it("copies a chat before its first queued message, which lands as that chat's draft", async () => { - const rows = await seedLegacyChat('chat-a') - const { sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - expect(journal.importPending).toBe(true) - - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, - fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' - }) - - expect(journal.importPending).toBe(false) - expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) - expect(existsSync(legacyFile('chat-a'))).toBe(false) - expect(importCount()).toBe(1) - expect(journal.queuedMessages.list()).toMatchObject([ - { messageId: 'draft-1', state: 'waiting' } - ]) - }) - - it('never shows a read racing the copy a partly copied chat', async () => { - // Past one import batch, so the copy yields to other work between batches. - const rows = await seedLegacyChat('chat-a', 520) - const { lifetime, sessions } = await restore(['chat-a']) - const journal = sessions.get('chat-a')!.journal - const cursor = { epoch: rows[0]!.epoch, sequence: 0 } - const seen: { published: boolean; copied: number; folded: number }[] = [] - const reads: Promise[] = [] - let ticking = true - const tick = (): void => { - seen.push({ - published: readJournalSessionEpoch(hostDb(), 'chat-a') !== null, - copied: Number(hostDb().prepare('SELECT count(*) AS n FROM journal_rows').get()?.n), - folded: journal.cursor().sequence - }) - reads.push( - lifetime.conversation('chat-a').then(({ journal: read }) => { - const since = read.readSince(cursor) - return since.ok ? since.rows.length : -1 - }) - ) - if (ticking) { - setImmediate(tick) - } - } - setImmediate(tick) - - await lifetime.conversation('chat-a') - ticking = false - await new Promise((resolve) => setImmediate(resolve)) - - // The copy was under way while other work ran, and none of it saw part of the chat. - expect(seen.some((turn) => !turn.published && turn.copied > 0)).toBe(true) - expect(seen.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) - expect(seen.every((turn) => turn.folded === rows.length)).toBe(true) - expect(new Set(await Promise.all(reads))).toEqual(new Set([rows.length])) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 181971ea42e..8221d257017 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -63,8 +63,7 @@ export function structuredAgentSessionSendBlock( return null } -/** The conversation a send or a Stop writes to, opened when this host holds it closed. Once it - * answers, every write issued before it has settled, so a mutation reads a whole fold. */ +/** The conversation a send or a Stop writes to, opened when this host holds it closed. */ export async function openConversationForWrite( openConversation: (sessionId: string) => Promise, envelope: AgentSessionMutationEnvelope, @@ -84,17 +83,6 @@ export async function openConversationForWrite( if (!session) { return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } - // Writes wait behind a restore's owed import. A failed import settled them too (they failed - // with it), so it is reported and never refuses the mutation: its own writes fail as they would. - if (session.journal.importPending) { - await session.journal.whenImported().catch((error: unknown) => { - logger.warn('the import owed before a write failed', { - scope: 'open-for-write', - sessionId: envelope.sessionId, - error - }) - }) - } return { ok: true } } 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 6981ff79cf1..f17ee460653 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 @@ -26,6 +26,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -135,10 +136,9 @@ function startAgent(): Promise { describe('settled attach retry', () => { it('settles a post-acquisition journal failure and retries without a restart', async () => { const journalDatabase = openTestJournalHostDatabase(root) - // The journal's open asks where the chat's per-chat file lives before it reads anything. - vi.spyOn(journalDatabase, 'legacyDirectoryFor').mockImplementationOnce(() => { - throw new Error('journal path unavailable') - }) + vi.spyOn(AgentSessionJournal.prototype, 'open').mockRejectedValueOnce( + new Error('journal path unavailable') + ) host = new StructuredAgentSessionHost({ agents: NO_STRUCTURED_AGENTS, logger: createStructuredAgentSessionLogger(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts index f2f3ad7ad69..4cbc39073c1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stop-never-waits.test.ts @@ -1,6 +1,5 @@ // A Stop's interrupt never waits on the journal. Its withdrawal and its Stop event are bookkeeping: -// one that throws is reported and the Stop still interrupts. Its writes may wait behind owed work; -// they are issued, and the interrupt goes out before they land. +// one that throws is reported and the Stop still interrupts. import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' @@ -216,75 +215,4 @@ describe.each([ await vi.waitFor(() => expectReported(failed)) } ) - - // The open pays an import owed before the Stop, so the work falls owed at the Stop's first write: - // the one moment the Stop's own writes can wait behind it. - it('interrupts before owed work its writes wait behind is paid', async () => { - const journal = await runningTurn() - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - let answered = false - const stopping = stop(fields).finally(() => { - answered = true - }) - try { - await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce()) - expect(journal.importPending).toBe(true) - expect(answered).toBe(false) - } finally { - owed.resolve() - } - expect(await stopping).toMatchObject({ ok: true }) - expect(journal.importPending).toBe(false) - }) - - // Under owed work the event is issued before the interrupt but lands later, still in queue order: - // ahead of the stopped turn's end, which the provider hands over as it takes the interrupt. - it('issues its Stop event before the interrupt, and it lands ahead of the turn it ends', async () => { - const journal = await runningTurn() - const order: string[] = [] - const owed = Promise.withResolvers() - const withdraw = journal.rejectQueuedSubmissions.bind(journal) - vi.spyOn(journal, 'rejectQueuedSubmissions').mockImplementation((...args) => { - journal['queue'].owe(() => owed.promise) - return withdraw(...args) - }) - const appendStopEvent = journal.appendStopEvent.bind(journal) - vi.spyOn(journal, 'appendStopEvent').mockImplementation((...args) => { - order.push('event issued') - return appendStopEvent(...args) - }) - cancelTurn.mockImplementation(async () => { - order.push('interrupt') - acquire.mock.calls - .at(-1)![0] - .events!.appendItem( - { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 900 }, - { kind: 'turn', turnId: 'turn-1', state: 'interrupted' }, - { turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - return { cancelled: true } - }) - - const stopping = stop(fields) - try { - await vi.waitFor(() => expect(cancelTurn).toHaveBeenCalledOnce(), INTERRUPT_WAIT) - } finally { - owed.resolve() - } - expect(await stopping).toMatchObject({ ok: true }) - - expect(order).toEqual(['event issued', 'interrupt']) - const since = journal.readSince({ epoch: journal.epoch, sequence: 0 }) - const rows = since.ok ? since.rows : [] - const stopRow = rows.find((row) => row.kind === 'tombstone' && row.stopEvent) - const turnEnd = rows.find( - (row) => row.kind === 'item' && row.body.kind === 'turn' && row.body.state === 'interrupted' - ) - expect(stopRow?.seq).toBeLessThan(turnEnd?.seq ?? 0) - }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts index 0948a86e8fd..4af804263ae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.ts @@ -55,8 +55,7 @@ export async function performThreadGoalChange( let replacesGoal = false if (change.kind === 'set') { // Read before the objective row lands: that row is a message, not a goal transition. A goal - // transition the host accepted is already in the fold: it landed at its call, or before the - // open's owed import finished. + // transition the host accepted is already in the fold: it landed at its call. replacesGoal = ctx.journal.threadGoal() !== null } // Journal first: an active goal starts provider work at once, and the objective diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts index d9a152d0e6e..e9d6d04fec5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-transition.test.ts @@ -13,7 +13,6 @@ import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { JournalLifecycleMutationInput } from '../agent-session-journal/journal-row-builders' -import { openJournalOwingImport } from '../agent-session-journal/journal-owed-import-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createAgentSessionDeltaCoalescer } from './agent-session-delta-coalescer' import { @@ -87,36 +86,6 @@ async function rig(watermarks: Partial = { return { root, journal, deferred, sink: deferred.sink, publishes, bind } } -/** A bound sink over a chat whose copy into the host's database is still owed, so every write, - * the transition's included, waits in the journal's queue until the first one pays it. */ -async function owedRig() { - const root = await mkdtemp(join(tmpdir(), 'orca-transition-owed-')) - roots.push(root) - const { journal } = await openJournalOwingImport({ - stateDirectory: root, - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'grok', - providerHandle: { transport: 'acp', agent: 'grok', nativeId: 'provider-session-1' } - }, - now: () => 1_000 - }) - const failures: unknown[] = [] - const deferred = createDeferredStructuredAgentSessionEventSink({ - ...testEventSinkLogging(SESSION), - onFailed: (error) => failures.push(error) - }) - deferred.bind({ journal, fence: 1, publish: () => undefined }) - const ownKeys = () => - journal - .snapshot() - .items.map((item) => item.itemId) - .filter((itemId) => itemId.includes(SESSION)) - return { journal, deferred, sink: deferred.sink, failures, ownKeys, close: () => journal.close() } -} - describe('structured agent-session transitions', () => { it('lands its steps back to back, each resolved after the one before it', async () => { const { journal, deferred, sink, publishes, bind } = await rig() @@ -409,68 +378,6 @@ BEGIN SELECT RAISE(ABORT, 'second chunk refused'); END`) ['first', 'second', 'nested'].map((recordId) => agentJournalItemKey(identity(recordId))) ) }) - - it('runs its steps back to back after an owed import, ahead of a write issued after it', async () => { - const { journal, deferred, sink, ownKeys, close } = await owedRig() - try { - sink.tryAppendTransition?.({ - lifecycle: false, - publish: false, - steps: [ - itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), - itemStep((view) => - view.itemBody(agentJournalItemKey(identity('first'))) - ? { identity: identity('second'), body: tool('read', 'running') } - : null - ) - ] - }) - sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - expect(journal.importPending).toBe(true) - await expect(deferred.drained()).resolves.toEqual({ ok: true }) - - expect(journal.importPending).toBe(false) - expect(ownKeys()).toEqual( - ['first', 'second', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) - ) - } finally { - await close() - } - }) - - it('stops at a failed middle step after an owed import; a write accepted before the failure still lands', async () => { - const { deferred, sink, failures, ownKeys, close } = await owedRig() - try { - const third = vi.fn(() => ({ identity: identity('third'), body: tool('read', 'running') })) - sink.tryAppendTransition?.({ - lifecycle: false, - publish: false, - steps: [ - itemStep(() => ({ identity: identity('first'), body: tool('read', 'running') })), - itemStep( - () => ({ identity: identity('second'), body: tool('x'.repeat(10_000), 'running') }), - 64 - ), - itemStep(third) - ] - }) - // Accepted before the failure is known: the sink's existing rule lets it land. - sink.tryAppendItem?.(identity('later'), tool('later', 'running'), { - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await expect(deferred.drained()).resolves.toMatchObject({ ok: false }) - - expect(ownKeys()).toEqual( - ['first', 'later'].map((recordId) => agentJournalItemKey(identity(recordId))) - ) - expect(third).not.toHaveBeenCalled() - expect(failures).toHaveLength(1) - } finally { - await close() - } - }) }) describe('resolved lifecycle batches', () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts index e6c3c608ae2..474b2211774 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-cancel.ts @@ -33,8 +33,8 @@ import { structuredAgentSessionFailedStopMark } from './structured-agent-session import { sendStopCanTakeBack } from './structured-agent-session-unopened-send-withdrawal' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -/** Whether the fold reads working. Every write has landed by its call's return, and the open paid - * any owed import, so a Stop reads it without waiting on the write queue. */ +/** Whether the fold reads working. Every write has landed by its call's return, so a Stop reads it + * without waiting on the write queue. */ export function isMainAgentWorking( ctx: Pick ): boolean { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 192837888d1..4f575c1473b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -161,7 +161,7 @@ export async function performSend( } catch (error) { // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the - // journal already classified (a copy that did not verify). + // journal already classified (a failed transaction that will not roll back). if ( isAgentSessionRefusalError(error) || classifyJournalOpenFailure(error) === 'journalCorrupt' || diff --git a/src/main/runtime/agent-session-acquisition-failure-settlement.ts b/src/main/runtime/agent-session-acquisition-failure-settlement.ts index 1be4974fc65..68db039d6ce 100644 --- a/src/main/runtime/agent-session-acquisition-failure-settlement.ts +++ b/src/main/runtime/agent-session-acquisition-failure-settlement.ts @@ -9,7 +9,7 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import { assertFence, withLease } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** * How the failed attempt's provider process was accounted for. diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts deleted file mode 100644 index adbdf162ccc..00000000000 --- a/src/main/runtime/agent-session-backup-recovery-fence.ts +++ /dev/null @@ -1,42 +0,0 @@ -// 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 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 -// `live` lease means a provider handle proven at exactly `lease.runtimeFence`, asserted by -// `isValidAgentSessionRecord`, so a fence bumped without a re-proof — which cannot happen offline — -// made the record invalid, quarantined it on the next load, and dropped back to the same backup. -// -// 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 -// 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' -import type { AgentSessionStoreState } from './agent-session-record-store-file' - -export function raiseAgentSessionFencesAfterBackupRecovery(state: AgentSessionStoreState): void { - for (const [sessionId, record] of state.records) { - const floor = nextAgentSessionFence(record.lease) + 1 - if (!Number.isSafeInteger(floor)) { - throw new Error('agent_session_fence_exhausted') - } - state.records.set(sessionId, { - ...record, - lease: { - ...record.lease, - minimumNextFence: floor - } - }) - } -} diff --git a/src/main/runtime/agent-session-claim-key-retention.ts b/src/main/runtime/agent-session-claim-key-retention.ts index 80d13aed2b6..a447224b17b 100644 --- a/src/main/runtime/agent-session-claim-key-retention.ts +++ b/src/main/runtime/agent-session-claim-key-retention.ts @@ -1,7 +1,7 @@ // Retired execution-claim keys. Split from the store on the same rule its ledger admission is: // the state transition lives here, the transaction stays in the store. -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** Retired claim keys stay verifiable this long so a rotation cannot strand a running agent. */ export const AGENT_SESSION_CLAIM_KEY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 diff --git a/src/main/runtime/agent-session-conversation-command-record.ts b/src/main/runtime/agent-session-conversation-command-record.ts index 4aefce22d57..2cf5ee77376 100644 --- a/src/main/runtime/agent-session-conversation-command-record.ts +++ b/src/main/runtime/agent-session-conversation-command-record.ts @@ -1,5 +1,5 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { AgentSessionTabTable } from './agent-session-tab-table' import { foundAgentSessionRecord } from './agent-session-record-founding' import type { AgentSessionConversationCommandRecord } from '../../shared/agent-session-conversation-command' diff --git a/src/main/runtime/agent-session-lease-renewal.ts b/src/main/runtime/agent-session-lease-renewal.ts index 1a1f90dd7e1..afde35d88d1 100644 --- a/src/main/runtime/agent-session-lease-renewal.ts +++ b/src/main/runtime/agent-session-lease-renewal.ts @@ -1,7 +1,7 @@ import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' import { renewAgentSessionLease } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' export type AgentSessionLeaseRenewal = { sessionId: string diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts deleted file mode 100644 index d9a1d0268ab..00000000000 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ /dev/null @@ -1,469 +0,0 @@ -// 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 { - encodePersistedAgentSessionProviderHandle, - isAgentSessionProviderHandle -} from '../../shared/agent-session-provider-handle-encoding' -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 { AgentSessionRecordStore } from './agent-session-record-store' -import { legacyAgentSessionStorePath } from './agent-session-record-store-file' -import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' - -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 } -} - -/** The records file only ever held handles in their stored form. */ -function storedForm(value: Record): Record { - const chain = value.providerHandleChain - return Array.isArray(chain) - ? { - ...value, - providerHandleChain: chain.map((link: unknown) => - typeof link === 'object' && - link !== null && - 'handle' in link && - isAgentSessionProviderHandle(link.handle) - ? { ...link, handle: encodePersistedAgentSessionProviderHandle(link.handle) } - : link - ) - } - : value -} - -function legacyFile( - records: readonly (Record & { sessionId: string })[], - extra: Record = {} -): Record { - return { - schemaVersion: 2, - hostId: 'local', - records: Object.fromEntries(records.map((value) => [value.sessionId, storedForm(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: unknown[] -}> { - const log = recordingStructuredAgentSessionLogger() - const database = await openStructuredAgentSessionJournalDatabase({ - logger: log.logger, - stateDirectory: root, - hostId: 'local' - }) - opened.push(database) - // Each report reaches the log as one entry under its scope, its kind as the outcome. - const reports = log.entries - .filter((entry) => entry.fields.scope === 'legacy-record-import') - .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) - 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(storedForm(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(storedForm(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 deleted file mode 100644 index f632f008e76..00000000000 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ /dev/null @@ -1,166 +0,0 @@ -// 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 { encodeAgentSessionRecord } from '../../shared/agent-session-record-stored-form' -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( - encodeAgentSessionRecord({ ...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(encodeAgentSessionRecord(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-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index 6cc192adbe1..d6d478c0190 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -20,7 +20,7 @@ import { } from '../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' export type AgentSessionOperationAdmission = { callerKey: string diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index bc691038a6b..ecc9fd9d72c 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -1,5 +1,5 @@ // 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. +// and exactly the rows a transaction changed written back. // // 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. @@ -12,10 +12,7 @@ import { import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' 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 { AgentSessionStoreState } from './agent-session-store-state' import type { AgentSessionStoreRowWrites } from './agent-session-store-draft' import { isReadableAgentSessionStoreOperation, @@ -81,13 +78,8 @@ function unreadableRecordReason(value: unknown): string { * 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 { +export function loadAgentSessionStoreRows(db: Database.Database): AgentSessionStoreState { const state: AgentSessionStoreState = { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId, records: new Map(), operations: new Map(), retiredClaimKeys: [], @@ -163,8 +155,6 @@ export function loadAgentSessionStoreRows( } if (recorded) { state.sessionTabs = table - } else if (table.sessionIds().length > 0) { - state.unrecordedSessionTabs = table } return state } @@ -224,56 +214,3 @@ export function writeAgentSessionStoreRows( 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 deleted file mode 100644 index 6b5632a87ab..00000000000 --- a/src/main/runtime/agent-session-record-store-file.ts +++ /dev/null @@ -1,294 +0,0 @@ -/** - * 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 { readFile } from 'node:fs/promises' -import { join } from 'node:path' -import { - agentSessionOperationKey, - isAgentSessionOperationRow, - type AgentSessionOperationRow -} from '../../shared/agent-session-operation-ledger' -import { - AGENT_SESSION_RECORD_SCHEMA_VERSION, - isPersistedAgentSessionRecord, - type AgentSessionRecord -} from '../../shared/agent-session-record' -import { decodePersistedAgentSessionRecord } from '../../shared/agent-session-record-stored-form' -import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' - -export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const - -/** 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' - -export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } - -export type AgentSessionStoreState = { - schemaVersion: number - hostId: string - records: Map - operations: Map - retiredClaimKeys: RetiredAgentSessionClaimKey[] - /** Rows this build cannot validate, kept with a durable refusal reason. */ - 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 = { - state: AgentSessionStoreState - storeFound: boolean - /** True when the file was written by a newer schema; this host reads but never writes it. */ - readOnly: boolean - /** True when the primary file was unusable and the previous committed copy was used. */ - recoveredFromBackup: 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, - hostId, - records: new Map(), - operations: new Map(), - retiredClaimKeys: [], - unreadableRecords: new Map(), - sessionTabs: null - } -} - -function parseState(raw: string, hostId: string): Pick | null { - let parsed: unknown - try { - parsed = JSON.parse(raw) - } catch { - return null - } - if (typeof parsed !== 'object' || parsed === null) { - return null - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is read back as `unknown` and validated below before use. - const file = parsed as { - schemaVersion?: unknown - hostId?: unknown - records?: unknown - operations?: unknown - retiredClaimKeys?: unknown - unusableRecords?: unknown - sessionTabs?: unknown - visibleSessionIds?: unknown - } - if ( - !Number.isSafeInteger(file.schemaVersion) || - (file.schemaVersion as number) < 0 || - typeof file.hostId !== 'string' - ) { - return null - } - const schemaVersion = file.schemaVersion as number - if (schemaVersion < AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - if ( - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && - (typeof file.records !== 'object' || file.records === null || Array.isArray(file.records)) - ) { - return null - } - if ( - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION && - (typeof file.operations !== 'object' || - file.operations === null || - Array.isArray(file.operations) || - !Array.isArray(file.retiredClaimKeys) || - typeof file.unusableRecords !== 'object' || - file.unusableRecords === null || - Array.isArray(file.unusableRecords)) - ) { - return null - } - const state = emptyState(hostId) - state.schemaVersion = schemaVersion - state.hostId = file.hostId - if (typeof file.records === 'object' && file.records !== null) { - for (const [sessionId, value] of Object.entries(file.records)) { - const decoded = isPersistedAgentSessionRecord(value) - ? decodePersistedAgentSessionRecord(value) - : null - const record = decoded?.record ?? null - if (record?.sessionId === sessionId) { - state.records.set(sessionId, record) - } else { - const valueSchemaVersion = - typeof value === 'object' && - value !== null && - (value as { schemaVersion?: unknown }).schemaVersion - const reason = record - ? 'record_key_session_id_mismatch' - : valueSchemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION - ? 'current_shape_invalid' - : 'unsupported_schema' - state.unreadableRecords.set(sessionId, { reason, raw: value }) - } - } - } - if (typeof file.unusableRecords === 'object' && file.unusableRecords !== null) { - for (const [sessionId, value] of Object.entries(file.unusableRecords)) { - if (typeof value !== 'object' || value === null) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - const unusable = value as { reason?: unknown; raw?: unknown } - if (typeof unusable.reason !== 'string' || unusable.reason.length === 0) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.unreadableRecords.set(sessionId, { reason: unusable.reason, raw: unusable.raw }) - } - } - if (typeof file.operations === 'object' && file.operations !== null) { - for (const [key, value] of Object.entries(file.operations)) { - if (!isAgentSessionOperationRow(value)) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - if (key !== agentSessionOperationKey(value.callerKey, value.operationId)) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.operations.set(key, value) - } - } - if (Array.isArray(file.retiredClaimKeys)) { - for (const entry of file.retiredClaimKeys) { - const key = entry as Partial - if ( - typeof key?.keyId !== 'string' || - key.keyId.length === 0 || - key.keyId.length > 512 || - !Number.isSafeInteger(key.retiredAt) || - (key.retiredAt as number) < 0 - ) { - if (schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION) { - return null - } - continue - } - state.retiredClaimKeys.push({ keyId: key.keyId, retiredAt: key.retiredAt as number }) - } - } - const sessionTabs = parseAgentSessionTabTable( - file, - state.records, - schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION - ) - if (!sessionTabs.valid) { - return null - } - state.sessionTabs = sessionTabs.table - return { state } -} - -/** A record the primary retained as unreadable may still have a valid copy in the previous - * committed state. Adopting it keeps the session reachable — the lease is re-adjudicated - * like any other — while the unreadable bytes stay quarantined verbatim. */ -async function salvageUnreadableRecordsFromBackup( - state: AgentSessionStoreState, - backupFilePath: string, - hostId: string -): Promise { - const missing = [...state.unreadableRecords.keys()].filter( - (sessionId) => !state.records.has(sessionId) - ) - if (missing.length === 0) { - return - } - let raw: string - try { - raw = await readFile(backupFilePath, 'utf-8') - } catch { - return - } - const backup = parseState(raw, hostId) - if (!backup) { - return - } - for (const sessionId of missing) { - const record = backup.state.records.get(sessionId) - if (record) { - state.records.set(sessionId, record) - } - } -} - -export async function loadAgentSessionStore( - filePath: string, - hostId: string -): Promise { - let unusableStoreFound = false - for (const [candidate, recoveredFromBackup] of [ - [filePath, false], - [backupPath(filePath), true] - ] as const) { - let raw: string - try { - raw = await readFile(candidate, 'utf-8') - } 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 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 - } - const parsed = parseState(raw, hostId) - if (!parsed) { - unusableStoreFound = true - continue - } - if (!recoveredFromBackup) { - await salvageUnreadableRecordsFromBackup(parsed.state, backupPath(filePath), hostId) - } - return { - ...parsed, - storeFound: true, - readOnly: parsed.state.schemaVersion > AGENT_SESSION_STORE_SCHEMA_VERSION, - recoveredFromBackup - } - } - if (unusableStoreFound) { - throw new Error('agent_session_store_corrupt') - } - return { - state: emptyState(hostId), - storeFound: false, - readOnly: false, - recoveredFromBackup: false - } -} diff --git a/src/main/runtime/agent-session-record-store-slot.ts b/src/main/runtime/agent-session-record-store-slot.ts index e40ddbd19b0..b19e0c8dfda 100644 --- a/src/main/runtime/agent-session-record-store-slot.ts +++ b/src/main/runtime/agent-session-record-store-slot.ts @@ -74,7 +74,7 @@ export function peekOpenedAgentSessionRecordStore(): AgentSessionRecordStore | n async function openRecordStore( location: AgentSessionRecordStoreLocation ): Promise { - const journalDatabase = await openStructuredAgentSessionJournalDatabase(location) + const journalDatabase = openStructuredAgentSessionJournalDatabase(location) try { return { journalDatabase, 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 ab879fa159b..4bab4ab7965 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -19,17 +19,12 @@ import { } 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_SCHEMA_VERSION, - type RetiredAgentSessionClaimKey -} from './agent-session-record-store-file' +import type { RetiredAgentSessionClaimKey } from './agent-session-store-state' const TEST_HOST_ID = 'local' /** 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 @@ -118,20 +113,23 @@ export function storedTestAgentSessionRecord( } } -/** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ +/** Leaves `records`, and any tab index, behind as an earlier run of the app would have, before + * anything opens it. */ export async function seedTestAgentSessionRecordStore( stateDirectory: string, - seed: { records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] } + seed: { + records: readonly (AgentSessionRecord | PersistedAgentSessionRecord)[] + sessionTabs?: { tabId: string; sessionId: string }[] + } ): Promise { writePersisted(databaseFor(stateDirectory), { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: TEST_HOST_ID, records: Object.fromEntries( seed.records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) ), operations: {}, retiredClaimKeys: [], - unusableRecords: {} + unusableRecords: {}, + ...(seed.sessionTabs ? { sessionTabs: seed.sessionTabs } : {}) }) } @@ -154,8 +152,6 @@ export async function readPersistedTestAgentSessionStore( ): Promise { const db = databaseFor(stateDirectory) const persisted: PersistedTestAgentSessionStore = { - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: TEST_HOST_ID, records: {}, operations: {}, retiredClaimKeys: [], diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index f05812ce941..e4f73dd3211 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -65,7 +65,7 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session-reservation-admission' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionVisibleTabIndex, listVisibleAgentSessionIds, @@ -101,7 +101,7 @@ export class AgentSessionRecordStore { journalDatabase: JournalHostDatabase hostId: string }): AgentSessionRecordStore { - const rows = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + const rows = loadAgentSessionStoreRows(args.journalDatabase.db) const transactions = new AgentSessionStoreTransactions(args.journalDatabase, rows) return new AgentSessionRecordStore(transactions, args.hostId) } diff --git a/src/main/runtime/agent-session-reservation-admission.test.ts b/src/main/runtime/agent-session-reservation-admission.test.ts index 7ae309560b0..3602aa29cd7 100644 --- a/src/main/runtime/agent-session-reservation-admission.test.ts +++ b/src/main/runtime/agent-session-reservation-admission.test.ts @@ -15,7 +15,7 @@ import { applyAgentSessionReservation, type AgentSessionReserveRequest } from './agent-session-reservation-admission' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { claudeProviderHandle, codexProviderHandle @@ -67,8 +67,6 @@ function reserveRequest( function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionStoreState { return { - schemaVersion: 2, - hostId: 'local', records: new Map(records.map((record) => [record.sessionId, record])), operations: new Map(), retiredClaimKeys: [], diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index 1344cc128d0..5fbf939d1af 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -45,7 +45,7 @@ import { reserveAgentSessionOwner, type AgentSessionReservation } from './agent-session-lease-transitions' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionRecordIdentityFields } from './agent-session-record-founding' export type AgentSessionReserveRequest = { diff --git a/src/main/runtime/agent-session-restart-reconciliation.ts b/src/main/runtime/agent-session-restart-reconciliation.ts index b3447ec85c8..3e05914c42e 100644 --- a/src/main/runtime/agent-session-restart-reconciliation.ts +++ b/src/main/runtime/agent-session-restart-reconciliation.ts @@ -1,7 +1,7 @@ import { pruneAgentSessionOperationRows } from '../../shared/agent-session-operation-ledger' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' import { agentSessionReconciliationTargetMatches } from './agent-session-reconciliation-target' import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions' diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts index e39027d0042..ac7e13d6377 100644 --- a/src/main/runtime/agent-session-store-draft.ts +++ b/src/main/runtime/agent-session-store-draft.ts @@ -7,7 +7,7 @@ import { encodeAgentSessionRecord } from '../../shared/agent-session-record-stor import type { AgentSessionStoreState, RetiredAgentSessionClaimKey -} from './agent-session-record-store-file' +} from './agent-session-store-state' import { isReadableAgentSessionStoreOperation, isReadableAgentSessionStoreRecord, diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts index d3e7ea1c038..96549f1e1a4 100644 --- a/src/main/runtime/agent-session-store-row-rules.ts +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -12,7 +12,7 @@ import { 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 { RetiredAgentSessionClaimKey } from './agent-session-store-state' import type { PersistedAgentSessionTab } from './agent-session-tab-table' /** Valid stored identity, independent of provider availability. */ diff --git a/src/main/runtime/agent-session-store-state.ts b/src/main/runtime/agent-session-store-state.ts new file mode 100644 index 00000000000..34d46a6bf1f --- /dev/null +++ b/src/main/runtime/agent-session-store-state.ts @@ -0,0 +1,18 @@ +// The agent-session store's in-memory state: loaded from the chat journal database and written +// back to it row by row (agent-session-record-rows.ts). + +import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { AgentSessionTabTable } from './agent-session-tab-table' + +export type RetiredAgentSessionClaimKey = { keyId: string; retiredAt: number } + +export type AgentSessionStoreState = { + records: Map + operations: Map + retiredClaimKeys: RetiredAgentSessionClaimKey[] + /** Rows this build cannot validate, kept with a durable refusal reason. */ + unreadableRecords: Map + /** Chat tab id → the conversation it shows; null until this store first records a tab. */ + sessionTabs: AgentSessionTabTable | null +} diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 31bddace873..778956762d2 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -11,7 +11,7 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' 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 type { AgentSessionStoreState } from './agent-session-store-state' import { writeAgentSessionStoreRows } from './agent-session-record-rows' import { agentSessionStoreDraftRowWrites, diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index e95e80d9d1e..3f8965c62f0 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -1,8 +1,6 @@ -import { isAgentSessionId, type AgentSessionRecord } from '../../shared/agent-session-record' import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' -import type { AgentSessionStoreState } from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-store-state' /** * Which conversation each structured chat tab shows, keyed by the host tab id. @@ -119,19 +117,11 @@ export function listVisibleAgentSessionIds(state: AgentSessionStoreState): strin return (state.sessionTabs?.sessionIds() ?? []).filter((sessionId) => state.records.has(sessionId)) } -/** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ export function agentSessionVisibleTabIndex(state: AgentSessionStoreState): { present: boolean sessionIds: string[] } { - return { - present: state.sessionTabs !== null, - sessionIds: state.sessionTabs - ? listVisibleAgentSessionIds(state) - : (state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => - state.records.has(sessionId) - ) - } + return { present: state.sessionTabs !== null, sessionIds: listVisibleAgentSessionIds(state) } } export function setAgentSessionTabVisibility( @@ -157,126 +147,9 @@ export function showAgentSessionTabs( ): void { for (const sessionId of sessionIds) { if (state.records.has(sessionId)) { - setAgentSessionTabVisibility( - state, - sessionId, - true, - state.unrecordedSessionTabs?.tabIdFor(sessionId) - ) + setAgentSessionTabVisibility(state, sessionId, true) } } } export type PersistedAgentSessionTab = { tabId: string; sessionId: string } - -/** - * 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 }, - records: ReadonlyMap, - strict: boolean -): { valid: boolean; table: AgentSessionTabTable | null } { - if (file.sessionTabs !== undefined) { - return parsePersistedTabs(file.sessionTabs, strict) - } - if (file.visibleSessionIds === undefined) { - return { valid: true, table: null } - } - if (!Array.isArray(file.visibleSessionIds)) { - return { valid: !strict, table: null } - } - return { valid: true, table: seedFromVisibleSessions(file.visibleSessionIds, records) } -} - -/** - * A cleared chat's tab was opened for the first conversation of its /clear chain and kept that id - * through every clear, so the chat now showing the chain's latest conversation seeds under the - * first one's id, as a /clear on this build would have left it. Those chats seed first: a cleared - * conversation reopened from history is the later tab, and takes a fresh id if its own is held. - */ -function seedFromVisibleSessions( - visible: readonly unknown[], - records: ReadonlyMap -): AgentSessionTabTable { - const sessionIds = [...new Set(visible.filter(isAgentSessionId))] - const clearedFrom = new Map() - for (const record of records.values()) { - const command = record.conversationCommand - if ( - command?.command === 'clear' && - command.phase === 'committed' && - command.replacementSessionId && - !clearedFrom.has(command.replacementSessionId) - ) { - clearedFrom.set(command.replacementSessionId, record.sessionId) - } - } - const clearedTo = new Set(clearedFrom.values()) - const chainRoot = (sessionId: string): string => { - const seen = new Set([sessionId]) - let current = sessionId - let prior = clearedFrom.get(current) - while (prior !== undefined && !seen.has(prior)) { - seen.add(prior) - current = prior - prior = clearedFrom.get(current) - } - return current - } - const recordedOrDerived = (sessionId: string): string[] => { - const record = records.get(sessionId) - const recorded = record && 'surfaceTabId' in record ? record.surfaceTabId : undefined - return [recorded, structuredAgentSessionTabId(sessionId)].filter(isAgentSessionSurfaceTabId) - } - const tabIds = new Map() - const taken = new Set() - const held = (tabId: string): boolean => taken.has(tabId) - const assign = (sessionId: string, candidates: readonly string[]): void => { - const tabId = candidates.find((candidate) => !held(candidate)) ?? reopenedTabId(sessionId, held) - taken.add(tabId) - tabIds.set(sessionId, tabId) - } - const holdsChainTab = (sessionId: string): boolean => - !clearedTo.has(sessionId) && chainRoot(sessionId) !== sessionId - for (const sessionId of sessionIds.filter(holdsChainTab)) { - assign(sessionId, [...recordedOrDerived(chainRoot(sessionId)), ...recordedOrDerived(sessionId)]) - } - for (const sessionId of sessionIds.filter((sessionId) => !holdsChainTab(sessionId))) { - assign(sessionId, recordedOrDerived(sessionId)) - } - // In the visible list's order, which is the order older builds restored tabs in. - return new AgentSessionTabTable( - sessionIds.flatMap((sessionId) => { - const tabId = tabIds.get(sessionId) - return tabId === undefined ? [] : [[tabId, sessionId] as const] - }) - ) -} - -function parsePersistedTabs( - raw: unknown, - strict: boolean -): { valid: boolean; table: AgentSessionTabTable | null } { - if (!Array.isArray(raw)) { - return { valid: !strict, table: null } - } - const table = new AgentSessionTabTable() - for (const entry of raw) { - const tabId: unknown = entry?.tabId - const sessionId: unknown = entry?.sessionId - const wellFormed = - isAgentSessionSurfaceTabId(tabId) && - isAgentSessionId(sessionId) && - table.sessionIdFor(tabId) === undefined && - table.tabIdFor(sessionId) === undefined - if (wellFormed) { - table.show(sessionId, tabId) - } else if (strict) { - return { valid: false, table: null } - } - } - return { valid: true, table } -} 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 a4a2e6e71a6..7872e4f23da 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 @@ -61,11 +61,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - // 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])] + const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds await host?.restoreReadableSessions(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { @@ -95,12 +91,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable - // 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() + // No host means no one can say which chats exist; with none on disk, empty is the answer. this.structuredAgentSessionInventoryUnverifiable = - (!host || importOwed) && this.hasPersistedStructuredAgentSessionStore() + !host && this.hasPersistedStructuredAgentSessionStore() // This restore published quietly; subscribers still hold the frames that said "cannot tell". if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { this.notifyMobileSessionTabSnapshots() diff --git a/src/main/runtime/rpc/methods/structured-agent-session.test.ts b/src/main/runtime/rpc/methods/structured-agent-session.test.ts index e33c7800cab..370c0bb3f55 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.test.ts @@ -82,7 +82,7 @@ describe('agentSession.reveal', () => { }) it('publishes the tab even when the journal could not be read', async () => { - // A pre-SQLite chat restores to nothing, but attach still recovers it, so the tab is worth + // An unreadable chat restores to nothing, but attach still recovers it, so the tab is worth // publishing and the pane's hold finishes the job. Refusing here would strand it forever. hostCalls.revealSession.mockResolvedValueOnce({ sessionId: SESSION, diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 54c27fbfe96..c232fd43db9 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -1,13 +1,7 @@ -// 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. +// Opening the chat journal database for the host install. 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' import type { StructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' @@ -31,19 +25,12 @@ function logOpenFailureOnce(logger: StructuredAgentSessionLogger, error: unknown /** The journal database. A refusal is recorded for the gate and thrown to the caller; the next * install tries again. */ -export async function openStructuredAgentSessionJournalDatabase(args: { +export function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string - hostId: string logger: StructuredAgentSessionLogger -}): Promise { +}): JournalHostDatabase { try { - const opened = await JournalHostDatabase.open(args.stateDirectory, async () => - legacyAgentSessionRecordImport( - await readLegacyAgentSessionRecords(args.stateDirectory, args.hostId), - args.hostId, - (report) => reportLegacyRecordImport(args.logger, report) - ) - ) + const opened = JournalHostDatabase.open(args.stateDirectory) recordStructuredAgentSessionHostInstallRefusal(null) lastLoggedOpenFailure = null return opened @@ -55,15 +42,3 @@ export async function openStructuredAgentSessionJournalDatabase(args: { throw refusal } } - -function reportLegacyRecordImport( - logger: StructuredAgentSessionLogger, - report: LegacyAgentSessionRecordImportReport -): void { - const { kind, ...fields } = report - logger.warn('importing the chat records file did not complete', { - scope: 'legacy-record-import', - outcome: kind, - ...fields - }) -} 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 deleted file mode 100644 index 8495fb49f5d..00000000000 --- a/src/main/runtime/structured-agent-session-record-import-install.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -// 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' -import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' -import { storedTestAgentSessionRecord } from './agent-session-record-store-test-harness' - -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]: storedTestAgentSessionRecord(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(log: ReturnType) { - 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({ - logger: log.logger, - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveLaunchArgs: () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) - }) - internal.refreshMobileSessionPtyRecords = async () => new Set() - return { - runtime, - host: () => internal.ensureStructuredAgentSessionHost(), - unverifiable: () => internal.structuredAgentSessionInventoryUnverifiable - } -} - -/** Each import report the install logged, its kind as the entry's outcome. */ -function importReports(log: ReturnType): unknown[] { - return log.entries - .filter((entry) => entry.fields.scope === 'legacy-record-import') - .map(({ fields: { scope: _scope, outcome, ...rest } }) => ({ kind: outcome, ...rest })) -} - -// 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 log = recordingStructuredAgentSessionLogger() - const first = startupRuntime(log) - - await first.runtime.restoreStructuredAgentSessionTabs() - const host = await first.host() - - expect(importReports(log)).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(recordingStructuredAgentSessionLogger()) - 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 log = recordingStructuredAgentSessionLogger() - const { runtime, host } = startupRuntime(log) - - await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - - expect(importReports(log)).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 0b65c556db8..23249909806 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -1,10 +1,9 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore, @@ -86,9 +85,6 @@ describe('structured session rollback compatibility', () => { runtimeKind: 'native' }) await seedTestAgentSessionRecordStore(root, { records: [record] }) - const journalDir = journalDirectoryFor(root, { workspaceId: WORKSPACE, sessionId: SESSION }) - await mkdir(journalDir, { recursive: true }) - await writeFile(join(journalDir, 'journal.log'), 'durable-journal-fixture\n') const target = await openTestAgentSessionRecordStore(root) expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] }) @@ -120,9 +116,6 @@ describe('structured session rollback compatibility', () => { const reloaded = await openTestAgentSessionRecordStore(root) expect(reloaded.listVisibleSessionIds()).toEqual([SESSION]) expect(reloaded.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) - await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( - 'durable-journal-fixture\n' - ) expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ { tabId: expect.any(String), sessionId: SESSION } ]) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 30a0c423bce..c6b663bf0c1 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 { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionClaimStatus, @@ -10,15 +10,10 @@ 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 @@ -72,17 +67,9 @@ describe('structured agent-session store presence', () => { } }) - async function openProfileDatabase( - legacyRecords: JournalLegacyRecordImport = NO_LEGACY_JOURNAL_RECORDS - ): Promise { + async function openProfileDatabase(): 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, '{}') + return JournalHostDatabase.open(profile) } // Every host install creates the database, chats or not; startup restore must not wait on one. @@ -102,14 +89,6 @@ describe('structured agent-session store presence', () => { 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') @@ -117,26 +96,11 @@ describe('structured agent-session store presence', () => { 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( - 2, - join('/profile', 'agent-sessions', 'agent-sessions.json') - ) - expect(fileExists).toHaveBeenNthCalledWith( - 3, - join('/profile', 'agent-sessions', 'agent-sessions.json.bak') - ) - }) - - it('reports a fresh profile absent after three bounded presence checks', () => { + it('reports a fresh profile absent after one presence check', () => { const fileExists = vi.fn(() => false) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false) - expect(fileExists).toHaveBeenCalledTimes(3) + expect(fileExists).toHaveBeenCalledTimes(1) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 7a833a74d53..d8a5698b025 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -37,7 +37,6 @@ import { releaseAgentSessionRecordStore, type OpenedAgentSessionRecordStore } from './agent-session-record-store-slot' -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 { @@ -64,26 +63,21 @@ import { } from './structured-agent-model-catalog-wiring' import type { ClaudeThinkingDisplaySupport } from '../claude/claude-thinking-display-support' -/** 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. */ +/** Whether this profile holds a structured chat: a record or tab in the journal database. */ export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { 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 - } + if (!fileExists(databasePath)) { + return false + } + try { + return journalDatabaseHoldsAgentSessions(databasePath) + } catch { + // A database that cannot be read cannot say it is empty. + return true } - const filePath = legacyAgentSessionStorePath(stateDirectory) - return fileExists(filePath) || fileExists(`${filePath}.bak`) } export type StructuredAgentSessionRuntimeDeps = { 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 bed708ae29a..0b3b8d9b307 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 @@ -3,9 +3,9 @@ // bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs // a bounded number of failed writes, not one per chat. -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, 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, vi } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' @@ -15,7 +15,9 @@ import { } from '../../shared/agent-session-record.test-fixture' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { + closeTestJournalHostDatabase, closeTestJournalHostDatabases, + openTestJournalHostDatabase, SAVED_BY_NEWER_ORCA } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' @@ -24,24 +26,19 @@ import { attachParamsForRecord } from '../native-chat/agent-session-wire/structu 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, - legacyAgentSessionStorePath -} from './agent-session-record-store-file' import { readPersistedTestAgentSessionStore, - seedTestAgentSessionStoreFromNewerBuild, - storedTestAgentSessionRecord + seedTestAgentSessionRecordStore, + 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' -import { createStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger' import { recordingStructuredAgentSessionLogger } from '../native-chat/agent-session-wire/structured-agent-session-logger-test-support' import { codexProviderHandle } from '../../shared/agent-session-provider-handle-encoding' +import { structuredAgentSessionTabId } from '../../shared/structured-agent-session-projection' // `failing` fails every record write; `grants` lets that many more through, then fails. const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) @@ -65,7 +62,6 @@ 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 @@ -119,34 +115,25 @@ function chatRecord( return { ...record, ...codex, ...clear } } -/** 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. */ +/** The chat records the last run left; `visible` is their saved tab index, absent on a legacy + * profile. `newer` leaves them 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[]; history?: AgentSessionRecord[] } = {} ) { - await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) - await writeFile( - legacyAgentSessionStorePath(root), - JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: 'local', - records: Object.fromEntries( - records.map((record) => [record.sessionId, storedTestAgentSessionRecord(record)]) - ), - operations: {}, - retiredClaimKeys: [], - unusableRecords: {}, - ...(options.visible ? { visibleSessionIds: options.visible } : {}) - }) - ) - const database = await openStructuredAgentSessionJournalDatabase({ - logger: createStructuredAgentSessionLogger(), - stateDirectory: root, - hostId: 'local' + await seedTestAgentSessionRecordStore(root, { + records, + ...(options.visible + ? { + sessionTabs: options.visible.map((sessionId) => ({ + tabId: structuredAgentSessionTabId(sessionId), + sessionId + })) + } + : {}) }) + const database = openTestJournalHostDatabase(root) for (const record of options.history ?? records) { const fence = record.lease.runtimeFence const journal = await openAgentSessionJournal({ @@ -171,33 +158,13 @@ async function seedProfile( }) await journal.close() } - database.close() + closeTestJournalHostDatabase(root) 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({ - logger: createStructuredAgentSessionLogger(), - stateDirectory: root, - hostId: 'local' - }) - database.db - .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') - .run(record.sessionId, JSON.stringify(storedTestAgentSessionRecord(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[] } = {}) { const log = recordingStructuredAgentSessionLogger() const runtime = new OrcaRuntimeService() @@ -403,26 +370,6 @@ describe('restoring the chat tabs open at quit', () => { 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() await seedProfile(records) diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts index 62e6da1d396..1ca0a6cf2be 100644 --- a/src/shared/agent-session-next-fence.ts +++ b/src/shared/agent-session-next-fence.ts @@ -1,10 +1,10 @@ // The only place a new fence number is chosen. // -// 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. +// Normally that is just "one past the current fence". For a record an earlier build's one-time +// import took from its 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 diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 96f31837eb0..395fff13adf 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -119,10 +119,11 @@ 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 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. + * Lowest fence a future grant may use. Set only by an earlier build's import of its records file, + * when the 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. */ minimumNextFence?: number /** Null on a released lease when nothing proved its owner gone. */