refactor(native-chat): remove the records-file and per-chat journal imports (#26038)

* refactor(native-chat): remove the records-file and per-chat journal imports

Every native chat user is on a build that already moved chat records and
history into the app-wide database, so the one-time copies are dead code:
the agent-sessions.json import and its owed-copy flag, the per-chat
journal.db import and the write-queue hold behind it, and the pre-SQLite
log.jsonl notice.

* refactor(native-chat): remove what only the deleted imports used

- JournalHostDatabase.unsyncedTransaction and the synchronous-pragma reset
  that undid it; JOURNAL_SYNCHRONOUS is now module-local. The stranded
  rollback test drives transaction() instead.
- deleteUnpublishedJournalRows and its SQL, plus its test.
- boundJournalStatusText.
- Stale comments naming the removed first-use copy (queued messages,
  send refusal example, JOURNAL_SYNCHRONOUS doc).
- Write-queue and readInOrder comments: a write also lags when issued
  behind one still waiting in line.
- Resolved-append ordering test binds the sink from inside a running read,
  so a resolver that read at handover now fails it.

* test(native-chat): run resolved-append in the SQLite runtime project
This commit is contained in:
Brennan Benson
2026-10-06 23:03:32 -07:00
committed by GitHub
parent 6bc529176b
commit acea59c6d8
81 changed files with 254 additions and 5355 deletions
@@ -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<T>(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()
}
})
})
@@ -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 (
@@ -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()
@@ -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
}
/**
@@ -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<Parameters<typeof openAgentSessionJournal>[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<void> {
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')
})
})
@@ -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}\``
)
}
}
}
@@ -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
}
@@ -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()
@@ -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<JournalLegacyRecordImport>
): Promise<JournalHostDatabase> {
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<T>(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<AgentSessionJournalIdentity, 'workspaceId' | 'sessionId'>
): 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
}
}
@@ -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)
@@ -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<JournalRefusalReason, 'journalWrittenByNewerOrca'>
/** 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
) {
@@ -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<JournalStoredRow[]> {
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<void> {
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()
}
}
@@ -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)
}
/** `<root>/agent-session-journal/<workspace>/<session>`. */
export function journalDirectoryFor(
root: string,
identity: Pick<AgentSessionJournalIdentity, 'workspaceId' | 'sessionId'>
): 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)
}
@@ -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<typeof NodeFs>()
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<void> {
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<string[]> {
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<void> {
const actual = await vi.importActual<typeof NodeFs>('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<typeof all>) => {
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
}
})
@@ -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 `<legacyDir>/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<JournalHostDatabase, Map<string, Promise<unknown>>>()
export function importPerSessionJournal(
input: ImportInput
): Promise<PerSessionJournalImportOutcome> {
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<PerSessionJournalImportOutcome> {
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<ImportInput, 'database' | 'identity' | 'legacyDirectory' | 'openSource'>
): Promise<JournalLoad | null> {
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<void> {
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<string>()
/**
* 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<ImportBatch>,
epoch: string
): Promise<void> {
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<ImportBatch>): Promise<string> {
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<ImportBatch> {
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
}
}
@@ -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' }
}
@@ -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<ImportBatch> {
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<JournalLoad> {
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.
}
}
@@ -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(
@@ -344,7 +344,7 @@ export class JournalQueuedMessages {
* no hook), then retention runs.
*/
repairAndPrune(): Promise<void> {
// 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()
}
@@ -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)
}
@@ -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: <T>(run: JournalWriteBody<T>) => Promise<T>
/** 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>) => void
database: () => JournalHostDatabase
state: () => JournalReducerState
readOnly: () => boolean
@@ -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 =
@@ -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<typeof journalFileFormatRemnantDisclosure>
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<unknown>
agent: AgentType
highestFence: () => number
}): Promise<void> {
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<unknown>
highestFence: () => number
}): Promise<void> {
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. */
@@ -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<void> {
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
})
}
@@ -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()
@@ -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<void> {
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<T>(read: () => T): Promise<T> {
return this.queue.readInOrder(read)
}
@@ -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<AgentSessionJournal> {
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<unknown> | 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<void>()
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<unknown> = Promise.resolve()
let read: Promise<string> = 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')
})
@@ -12,14 +12,13 @@ export type JournalWriteResult<T> = T extends PromiseLike<unknown> ? never : T
export type JournalWriteBody<T> = () => JournalWriteResult<T>
/**
* 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<void>) | 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<T>(read: () => T): Promise<T> {
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>): void {
this.owed = work
}
get owing(): boolean {
return this.owed !== null
}
private payOwed = async (): Promise<void> => {
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<void> {
return this.writes.then(() => undefined)
@@ -107,8 +86,8 @@ export class JournalWriteQueue {
return result
}
private join<T>(run: () => T, paysOwed: boolean): Promise<T> {
const started = paysOwed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run)
private join<T>(run: () => T): Promise<T> {
const started = this.writes.then(run)
this.writes = started.catch(() => undefined)
this.waiting++
const settle = (): void => {
@@ -78,7 +78,7 @@ export function mutateWithChatStop<TValue>(
// 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,
@@ -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<StructuredAgentSessionHostSession> => {
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
})
@@ -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)
@@ -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(() => {
@@ -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', () => {
@@ -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<void> => this.restore.reconcileRestartLeases()
@@ -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<typeof PerSessionImport>()
return {
...actual,
// What the copy's verify throws for a file that does not read back as copied.
importPerSessionJournal: async (
input: Parameters<typeof actual.importPerSessionJournal>[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<typeof actual.previewPerSessionJournal>[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<StructuredAgentSessionHost> {
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()
})
@@ -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<StructuredAgentSessionAdapter['acquire']>
let cancelTurn: Mock<StructuredAgentSessionAdapter['cancelTurn']>
let warned: ReturnType<typeof vi.spyOn>
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<void> {
const owed = Promise.withResolvers<void>()
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') })
)
})
})
@@ -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<void>()
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)
})
})
@@ -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<string> {
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()
@@ -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)
}
@@ -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(
@@ -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')
})
@@ -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<string>(),
openReadOnly: new Set<object>()
}))
// 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<typeof SyncDatabaseModule>()
class RecordingDatabase extends actual.default {
constructor(...args: ConstructorParameters<typeof actual.default>) {
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<JournalStoredRow[]> {
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<JournalStoredRow[]> {
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<typeof createStructuredAgentSessionConversationLifetime>[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<string, JournalStoredRow[]>()
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<number>[] = []
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]))
})
})
@@ -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<StructuredAgentSessionHostSession | null>,
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 }
}
@@ -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<unknown> {
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(),
@@ -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<void>()
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<void>()
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)
})
})
@@ -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
@@ -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<StructuredAgentSessionSinkWatermarks> = {
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', () => {
@@ -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<AgentSessionTurnContext, 'journal' | 'fence'>
): boolean {
@@ -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' ||
@@ -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.
@@ -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
}
})
}
}
@@ -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
@@ -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'
@@ -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
@@ -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<AgentSessionLease> = {}): 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<string, unknown>): Record<string, unknown> {
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<string, unknown> & { sessionId: string })[],
extra: Record<string, unknown> = {}
): Record<string, unknown> {
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<void> {
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)
})
})
@@ -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<LegacyAgentSessionRecords> {
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) }
}
@@ -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
+3 -66
View File
@@ -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)
}
}
@@ -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<string, AgentSessionRecord>
operations: Map<string, AgentSessionOperationRow>
retiredClaimKeys: RetiredAgentSessionClaimKey[]
/** Rows this build cannot validate, kept with a durable refusal reason. */
unreadableRecords: Map<string, { reason: string; raw: unknown }>
/** 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<LoadedAgentSessionStore, 'state'> | 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<RetiredAgentSessionClaimKey>
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<void> {
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<LoadedAgentSessionStore> {
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
}
}
@@ -74,7 +74,7 @@ export function peekOpenedAgentSessionRecordStore(): AgentSessionRecordStore | n
async function openRecordStore(
location: AgentSessionRecordStoreLocation
): Promise<OpenedAgentSessionRecordStore> {
const journalDatabase = await openStructuredAgentSessionJournalDatabase(location)
const journalDatabase = openStructuredAgentSessionJournalDatabase(location)
try {
return {
journalDatabase,
@@ -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<string, PersistedAgentSessionRecord>
operations: Record<string, AgentSessionOperationRow>
@@ -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<void> {
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<PersistedTestAgentSessionStore> {
const db = databaseFor(stateDirectory)
const persisted: PersistedTestAgentSessionStore = {
schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION,
hostId: TEST_HOST_ID,
records: {},
operations: {},
retiredClaimKeys: [],
@@ -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)
}
@@ -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: [],
@@ -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 = {
@@ -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'
@@ -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,
@@ -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. */
@@ -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<string, AgentSessionRecord>
operations: Map<string, AgentSessionOperationRow>
retiredClaimKeys: RetiredAgentSessionClaimKey[]
/** Rows this build cannot validate, kept with a durable refusal reason. */
unreadableRecords: Map<string, { reason: string; raw: unknown }>
/** Chat tab id → the conversation it shows; null until this store first records a tab. */
sessionTabs: AgentSessionTabTable | null
}
@@ -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,
+3 -130
View File
@@ -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<string, AgentSessionRecord>,
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<string, AgentSessionRecord>
): AgentSessionTabTable {
const sessionIds = [...new Set(visible.filter(isAgentSessionId))]
const clearedFrom = new Map<string, string>()
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<string, string>()
const taken = new Set<string>()
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 }
}
@@ -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()
@@ -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,
@@ -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> {
}): 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
})
}
@@ -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<void> {
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<typeof recordingStructuredAgentSessionLogger>) {
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<StructuredAgentSessionHost>
refreshMobileSessionPtyRecords(): Promise<Set<string> | 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<string>()
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<typeof recordingStructuredAgentSessionLogger>): 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')
})
@@ -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 }
])
@@ -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<JournalHostDatabase> {
async function openProfileDatabase(): Promise<JournalHostDatabase> {
profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-'))
return JournalHostDatabase.openWith(profile, legacyRecords)
}
async function writeRecordsFile(): Promise<void> {
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)
})
})
@@ -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 = {
@@ -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<void> {
// 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)
+5 -5
View File
@@ -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
+5 -4
View File
@@ -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. */