From 98a2e27cda92f5ee6550b5872b67aec15a6a0d73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:56:25 -0700 Subject: [PATCH 01/30] fix(native-chat): report a failed startup chat reconcile instead of failing app startup At startup the chat host re-checks every saved chat's lease and writes the result to agent-sessions.json. If that write failed (the file lock gave up, the file could not be written, or the file was written by a newer Orca and is read-only here), reconcileRestartLeases rejected, the startup IPC call rejected, and the renderer fell into its degraded "Session restore failed. Changes won't be saved until restart" mode. The reconcile is bookkeeping: a lease left unreconciled grants no writer, and every attach, send and read of a chat reconciles its own lease again. So the startup reconcile now reports its failure through a new optional host dependency, onStartupReconcileFailure, and resolves. The runtime routes it to its onError sink under the scope structured-agent-session-startup-reconcile, or logs it when no sink is installed (the desktop installs none). --- .../structured-agent-session-host-types.ts | 3 + .../structured-agent-session-host.ts | 13 +- ...uctured-agent-session-restart-reconcile.ts | 16 ++ ...-session-startup-reconcile-failure.test.ts | 145 ++++++++++++++++++ .../structured-agent-session-runtime.ts | 7 + ...ed-agent-session-startup-reconcile.test.ts | 110 +++++++++++++ 6 files changed, 287 insertions(+), 7 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts create mode 100644 src/main/runtime/structured-agent-session-startup-reconcile.test.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index 80ea85e86835..bb858f804838 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -120,6 +120,9 @@ export type StructuredAgentSessionHostDeps = { /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void + /** A startup reconcile that refused or threw. Startup carries on: every attach, send and read + * reconciles its own chat again. */ + onStartupReconcileFailure?: (failure: unknown) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ onSessionStatusChanged?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index efdd83d9f146..56f206db1a8d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -10,7 +10,10 @@ import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { createRestartReconciler } from './structured-agent-session-restart-reconcile' +import { + createRestartReconciler, + reconcileLeasesAtStartup +} from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' @@ -228,12 +231,8 @@ export class StructuredAgentSessionHost { } } - reconcileRestartLeases = async (): Promise => { - const refusal = await this.reconcileLeases('startup') - if (refusal) { - throw new Error(refusal.code) - } - } + reconcileRestartLeases = (): Promise => + reconcileLeasesAtStartup(this.reconcileLeases, this.deps.onStartupReconcileFailure) restoreReadableSessions = (sessionIds?: readonly string[]): Promise => this.restore.restoreReadableSessions(sessionIds) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 3b6cc30271e5..b4d1380090d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -45,6 +45,22 @@ export function createRestartReconciler(deps: { } } +/** Startup never fails on this verdict: a lease left unreconciled grants no writer, and the next + * attach, send or read of that chat reconciles it again. So a failure is reported, never owed. */ +export async function reconcileLeasesAtStartup( + reconcile: (sessionId: string) => Promise, + onFailure: ((failure: unknown) => void) | undefined +): Promise { + try { + const refusal = await reconcile('startup') + if (refusal) { + onFailure?.(refusal) + } + } catch (error) { + onFailure?.(error) + } +} + async function reconcileCurrentLeases(deps: { store: AgentSessionRecordStore probe: (record: AgentSessionRecord) => Promise diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts new file mode 100644 index 000000000000..0e96294872bc --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -0,0 +1,145 @@ +// The startup reconcile is bookkeeping: a lock that gives up or a store this build may not write +// is reported, and startup carries on. Nothing is owed after it, because every chat reconciles its +// own lease again when it is sent to or read. + +import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import type * as FileTransactionLock from '../../file-transaction-lock' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath +} from '../../runtime/agent-session-record-store-file' +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 { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' + +const lock = vi.hoisted(() => ({ failing: false })) + +vi.mock('../../file-transaction-lock', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + withFileTransactionLock: (...args: Parameters) => + lock.failing + ? // What proper-lockfile throws once its retries give up. + Promise.reject( + Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) + ) + : actual.withFileTransactionLock(...args) + } +}) + +const relaunchedRoots: string[] = [] + +afterEach(async () => { + lock.failing = false + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A host with one chat, relaunched over a copy of its files; `rewrite` edits the copied store. */ +async function relaunch(rewrite?: (json: Record) => void) { + const dying = hostTestState() + await attach() + // An empty renewal queues behind every record write, so they are on disk. + await dying.store.renewLeases([]) + const relaunched = `${dying.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(dying.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + const storeDirectory = join(relaunched, 'store') + if (rewrite) { + const path = agentSessionStorePath(storeDirectory) + const json = JSON.parse(await readFile(path, 'utf-8')) + rewrite(json) + await writeFile(path, JSON.stringify(json)) + } + const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) + const onStartupReconcileFailure = vi.fn() + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + probeOwner: async () => ({ outcome: 'pid-absent' }), + now: () => NOW, + onStartupReconcileFailure + }) + replaceHostTestState({ store, host }) + return { host, store, storeDirectory, onStartupReconcileFailure } +} + +it('reports a startup reconcile whose store write fails, and does not reject', async () => { + const { host, store, onStartupReconcileFailure } = await relaunch() + + lock.failing = true + await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() + + expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + expect(onStartupReconcileFailure).toHaveBeenCalledWith( + expect.objectContaining({ code: 'ELOCKED' }) + ) + // Nothing was adjudicated, so the lease still grants no writer. + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) +}) + +it('reconciles the chat on its next send once the store can be written again', async () => { + const { host, store, onStartupReconcileFailure } = await relaunch() + lock.failing = true + await host.reconcileRestartLeases() + expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + lock.failing = false + + const body = hostTestMessage('sent after a startup reconcile failed') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ ok: true }) + + // The send is queued; delivering it starts the agent, and that start reconciles the lease. + await vi.waitFor(() => expect(hostTestState().dispatch).toHaveBeenCalledOnce(), { + timeout: 10_000 + }) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) + expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + // Before the relaunched directory is removed, so the child's wind-down can write its lease. + await host.flushAllStreamedEvents() +}) + +it('reports a store a newer Orca wrote without writing it, and does not reject', async () => { + const { host, store, storeDirectory, onStartupReconcileFailure } = await relaunch((json) => { + json.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 + }) + expect(store.readOnly).toBe(true) + const path = agentSessionStorePath(storeDirectory) + const bytes = await readFile(path) + const files = await readdir(storeDirectory) + + await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() + + expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + expect(onStartupReconcileFailure).toHaveBeenCalledWith( + expect.objectContaining({ message: 'agent_session_legacy_required' }) + ) + expect(store.listRecords().map((record) => record.sessionId)).toEqual([SESSION]) + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(storeDirectory)).toEqual(files) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 24735c721cc2..9cdb980684f8 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -317,6 +317,13 @@ async function installOnJournal( : {}), onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), + onStartupReconcileFailure: (error) => + deps.onError + ? deps.onError({ scope: 'structured-agent-session-startup-reconcile', error }) + : console.warn( + '[structured-agent-session] reconciling chat leases at startup failed', + error + ), ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts new file mode 100644 index 000000000000..4b444eef70e1 --- /dev/null +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -0,0 +1,110 @@ +// A profile whose chat records a newer Orca wrote opens read-only. The startup reconcile cannot +// write there, which is bookkeeping: startup must still finish rather than put the whole app into +// its degraded "Session restore failed" mode, and the file must be left exactly as it was. + +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath +} from './agent-session-record-store-file' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-startup-reconcile-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** A profile whose record store a newer Orca wrote, with one chat in it. */ +async function seedNewerStore() { + const storeDirectory = join(root, 'agent-sessions') + const path = agentSessionStorePath(storeDirectory) + const record = agentSessionRecordFixture() + await mkdir(storeDirectory, { recursive: true }) + await writeFile( + path, + JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + 1, + hostId: 'local', + records: { [record.sessionId]: record }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + ) + return { storeDirectory, path, sessionId: record.sessionId } +} + +function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + ...(onError ? { onError } : {}) + }) + internal.refreshMobileSessionPtyRecords = async () => new Set() + return runtime +} + +it('finishes startup over records a newer Orca wrote, reports it, and writes nothing', async () => { + const { storeDirectory, path, sessionId } = await seedNewerStore() + const bytes = await readFile(path) + const files = await readdir(storeDirectory) + const onError = vi.fn() + const runtime = startupRuntime(onError) + + // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-startup-reconcile', + error: expect.objectContaining({ message: 'agent_session_legacy_required' }) + }) + expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') + await stopStructuredAgentSessionRuntime() + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(storeDirectory)).toEqual(files) +}) + +// The desktop installs its host with no error sink, so the failure is logged rather than dropped. +it('logs the failure when the host has no error sink', async () => { + await seedNewerStore() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect( + startupRuntime().prepareStructuredAgentSessionStartupRestoration() + ).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] reconciling chat leases at startup failed', + expect.objectContaining({ message: 'agent_session_legacy_required' }) + ) +}) From 872f31f915ccff1766d38c30430b5038cc605714 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:12:13 -0700 Subject: [PATCH 02/30] fix(native-chat): read restored chats without waiting on lease bookkeeping With native chat on and a chat tab open at quit, the renderer's startup also awaits the chat tab restore (session.tabs.listAll). That restore re-ran the lease reconcile before reading each chat and rethrew its store failure, then recorded each restored tab as visible through a store transaction that throws on a held lock or a read-only store. Either one failed the restore, so startup still fell into "Session restore failed". Reading a chat grants no writer, so the reconcile startup and the restore run is now a reader's: createReaderReconcile never throws, answers whether every lease is settled (recovery is resolved only then; the journal opens either way), and reports each distinct failure once until a reconcile settles. Attach and agent start keep the strict reconcile. The restore's tab republish logs a failed visibility write and still publishes the tab, since a client drops every unpublished chat tab; user-driven publishes still refuse. The host dependency is renamed onLeaseReconcileFailure (scope structured-agent-session-lease-reconcile), since it now also reports for reads. --- .../structured-agent-session-host-types.ts | 6 +- .../structured-agent-session-host.ts | 10 +- ...ed-agent-session-readable-restorer.test.ts | 2 +- ...uctured-agent-session-restart-reconcile.ts | 46 +++-- ...ured-agent-session-restart-restore.test.ts | 8 +- ...tructured-agent-session-restart-restore.ts | 7 +- ...ent-session-restore-without-import.test.ts | 2 +- .../structured-agent-session-reveal.ts | 18 +- ...-session-startup-reconcile-failure.test.ts | 59 +++++-- ...tore-structured-agent-session-tabs-once.ts | 16 +- ...runtime-structured-session-restore.test.ts | 3 +- .../structured-agent-session-runtime.ts | 9 +- ...ed-agent-session-startup-reconcile.test.ts | 160 ++++++++++++++++-- 13 files changed, 270 insertions(+), 76 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index bb858f804838..e2e8e07d4fb3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -120,9 +120,9 @@ export type StructuredAgentSessionHostDeps = { /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void - /** A startup reconcile that refused or threw. Startup carries on: every attach, send and read - * reconciles its own chat again. */ - onStartupReconcileFailure?: (failure: unknown) => void + /** A lease reconcile run for startup or a read that refused or threw, once per distinct failure. + * Startup and the read carry on: the next attach or send reconciles every lease again. */ + onLeaseReconcileFailure?: (failure: unknown) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ onSessionStatusChanged?: ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 56f206db1a8d..78bc7de155ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -10,10 +10,7 @@ import type { AgentJournalSnapshot } from '../../../shared/agent-session-journal import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type * as SessionWire from '../../../shared/agent-session-wire' import type { AgentSessionAttachParams } from './structured-agent-session-attach' -import { - createRestartReconciler, - reconcileLeasesAtStartup -} from './structured-agent-session-restart-reconcile' +import { createRestartReconciler } from './structured-agent-session-restart-reconcile' import type { AgentSessionSubscribeInput } from './structured-agent-session-subscribers' import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' import * as providerSupport from './structured-agent-session-provider-support' @@ -138,7 +135,7 @@ export class StructuredAgentSessionHost { flushStreamedEvents: (sessionId) => this.flushStreamedEvents(sessionId) }) this.restore = createStructuredAgentSessionHostRestore(deps, { - reconcile: this.reconcileLeases, + reconcileLeases: this.reconcileLeases, resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId), serialize: (sessionId, task) => this.serialize(sessionId, task), hasSession: this.hasSession, @@ -231,8 +228,7 @@ export class StructuredAgentSessionHost { } } - reconcileRestartLeases = (): Promise => - reconcileLeasesAtStartup(this.reconcileLeases, this.deps.onStartupReconcileFailure) + reconcileRestartLeases = (): Promise => this.restore.reconcileRestartLeases() restoreReadableSessions = (sessionIds?: readonly string[]): Promise => this.restore.restoreReadableSessions(sessionIds) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 5eaedfffb384..240882f17564 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -39,7 +39,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { adapter: {} }, supportsRecord: () => true, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index b4d1380090d5..c9cba45aa59d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -45,20 +45,46 @@ export function createRestartReconciler(deps: { } } -/** Startup never fails on this verdict: a lease left unreconciled grants no writer, and the next - * attach, send or read of that chat reconciles it again. So a failure is reported, never owed. */ -export async function reconcileLeasesAtStartup( +/** The reconcile a reader runs, at startup and before each restored read: it never throws, since + * an unreconciled lease grants no writer and the next send reconciles again before it acts. + * Answers whether every lease is settled; a failure is reported once until a reconcile settles. */ +export function createReaderReconcile( reconcile: (sessionId: string) => Promise, onFailure: ((failure: unknown) => void) | undefined -): Promise { - try { - const refusal = await reconcile('startup') - if (refusal) { - onFailure?.(refusal) +): (sessionId: string) => Promise { + let reported: string | null = null + return async (sessionId) => { + let failure: unknown + try { + const refusal = await reconcile(sessionId) + if (!refusal) { + reported = null + return true + } + failure = refusal + } catch (error) { + failure = error + } + // Every read re-runs a failing reconcile, so each distinct failure is reported once. + const key = failureKey(failure) + if (key !== reported) { + reported = key + onFailure?.(failure) + } + return false + } +} + +function failureKey(failure: unknown): string { + if (typeof failure === 'object' && failure !== null) { + if ('code' in failure && failure.code) { + return String(failure.code) + } + if ('message' in failure) { + return String(failure.message) } - } catch (error) { - onFailure?.(error) } + return String(failure) } async function reconcileCurrentLeases(deps: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index c7d04ea64cc4..3bf000f9e409 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -62,7 +62,7 @@ describe('restart journal restoration', () => { const restoration = restoreStructuredAgentSessionsOnRestart({ openDeps: NO_OPEN_DEPS, records, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, @@ -103,7 +103,7 @@ describe('restart journal restoration', () => { await restoreStructuredAgentSessionsOnRestart({ openDeps: NO_OPEN_DEPS, records, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => false, @@ -150,7 +150,7 @@ describe('restart journal restoration', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. records: [{ sessionId: 'session-1' } as AgentSessionRecord], openDeps: NO_OPEN_DEPS, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => { calls.push('resolveRecovery') }, @@ -174,7 +174,7 @@ describe('restart journal restoration', () => { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. records: [{ sessionId: 'session-1' } as AgentSessionRecord], openDeps: NO_OPEN_DEPS, - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 4e4b7f1e5ddd..02561594caa8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -13,7 +13,6 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { @@ -28,7 +27,8 @@ export type StructuredAgentSessionReadRestoreDeps = { openDeps: StructuredAgentSessionConversationOpenDeps & { store: Pick } - reconcile: (sessionId: string) => Promise + /** Whether every lease is settled. Never throws: a read grants no writer, so bookkeeping can't block it. */ + reconcile: (sessionId: string) => Promise resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean @@ -43,8 +43,7 @@ async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, sessionId: string ): Promise { - const unreconciled = await input.reconcile(sessionId) - if (!unreconciled) { + if (await input.reconcile(sessionId)) { // A session latched in recovery exits here at startup, without waiting for a client. await input.resolveRecovery(sessionId) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 226e6536c9c1..1308dbfe4a83 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -232,7 +232,7 @@ async function restore(sessionIds: readonly string[]) { await restoreStructuredAgentSessionsOnRestart({ openDeps: deps, records: sessionIds.map(recordFor), - reconcile: async () => null, + reconcile: async () => true, resolveRecovery: async () => undefined, serialize: async (_sessionId, task) => task(), hasSession: (sessionId) => sessions.has(sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 2a1899ab1ccd..96b0abd2b113 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -10,10 +10,12 @@ // a send does. And a journal it cannot open is not a refusal — the chat shows that failure with a // Retry, so the tab is worth publishing either way. +import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' +import { createReaderReconcile } from './structured-agent-session-restart-reconcile' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionReveal @@ -51,23 +53,31 @@ export async function revealStructuredAgentSession( } } -/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. */ +/** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. + * Its reconcile is a reader's, which never fails a read or startup; startup runs the same one. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], - 'openDeps' | 'supportsRecord' - > + 'openDeps' | 'supportsRecord' | 'reconcile' + > & { reconcileLeases: (sessionId: string) => Promise } ): { + reconcileRestartLeases: () => Promise restoreReadableSessions: (sessionIds?: readonly string[]) => Promise } { + const { reconcileLeases, ...rest } = wiring + const reconcile = createReaderReconcile(reconcileLeases, deps.onLeaseReconcileFailure) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), - ...wiring + reconcile, + ...rest }) const gate = new StructuredAgentSessionRestartRestoreGate() return { + reconcileRestartLeases: async () => { + await reconcile('startup') + }, restoreReadableSessions: (sessionIds) => gate.run(() => restorer.restore(sessionIds)) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 0e96294872bc..5e1c8a95868c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -1,6 +1,6 @@ -// The startup reconcile is bookkeeping: a lock that gives up or a store this build may not write -// is reported, and startup carries on. Nothing is owed after it, because every chat reconciles its -// own lease again when it is sent to or read. +// The lease reconcile is bookkeeping: a lock that gives up or a store this build may not write is +// reported, and startup and every read carry on. Nothing is owed after it, because an unreconciled +// lease grants no writer and the next send reconciles every lease again before it acts. import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' @@ -73,7 +73,7 @@ async function relaunch(rewrite?: (json: Record) => void) { await writeFile(path, JSON.stringify(json)) } const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) - const onStartupReconcileFailure = vi.fn() + const onLeaseReconcileFailure = vi.fn() const host = new StructuredAgentSessionHost({ store, adapter: adapter(), @@ -82,31 +82,29 @@ async function relaunch(rewrite?: (json: Record) => void) { mintSpawnToken: () => 'spawn-next', probeOwner: async () => ({ outcome: 'pid-absent' }), now: () => NOW, - onStartupReconcileFailure + onLeaseReconcileFailure }) replaceHostTestState({ store, host }) - return { host, store, storeDirectory, onStartupReconcileFailure } + return { host, store, storeDirectory, onLeaseReconcileFailure } } it('reports a startup reconcile whose store write fails, and does not reject', async () => { - const { host, store, onStartupReconcileFailure } = await relaunch() + const { host, store, onLeaseReconcileFailure } = await relaunch() lock.failing = true await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onStartupReconcileFailure).toHaveBeenCalledOnce() - expect(onStartupReconcileFailure).toHaveBeenCalledWith( - expect.objectContaining({ code: 'ELOCKED' }) - ) + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) // Nothing was adjudicated, so the lease still grants no writer. expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) }) it('reconciles the chat on its next send once the store can be written again', async () => { - const { host, store, onStartupReconcileFailure } = await relaunch() + const { host, store, onLeaseReconcileFailure } = await relaunch() lock.failing = true await host.reconcileRestartLeases() - expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() lock.failing = false const body = hostTestMessage('sent after a startup reconcile failed') @@ -119,13 +117,13 @@ it('reconciles the chat on its next send once the store can be written again', a timeout: 10_000 }) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) - expect(onStartupReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() // Before the relaunched directory is removed, so the child's wind-down can write its lease. await host.flushAllStreamedEvents() }) it('reports a store a newer Orca wrote without writing it, and does not reject', async () => { - const { host, store, storeDirectory, onStartupReconcileFailure } = await relaunch((json) => { + const { host, store, storeDirectory, onLeaseReconcileFailure } = await relaunch((json) => { json.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 }) expect(store.readOnly).toBe(true) @@ -135,11 +133,38 @@ it('reports a store a newer Orca wrote without writing it, and does not reject', await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onStartupReconcileFailure).toHaveBeenCalledOnce() - expect(onStartupReconcileFailure).toHaveBeenCalledWith( + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith( expect.objectContaining({ message: 'agent_session_legacy_required' }) ) expect(store.listRecords().map((record) => record.sessionId)).toEqual([SESSION]) expect(await readFile(path)).toEqual(bytes) expect(await readdir(storeDirectory)).toEqual(files) }) + +it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => { + const { host, store, onLeaseReconcileFailure } = await relaunch() + lock.failing = true + await host.reconcileRestartLeases() + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) +}) + +it('restores a chat for reading from a store a newer Orca wrote', async () => { + const { host, storeDirectory, onLeaseReconcileFailure } = await relaunch((json) => { + json.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 + }) + const path = agentSessionStorePath(storeDirectory) + const bytes = await readFile(path) + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(await readFile(path)).toEqual(bytes) +}) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 8d29a0d360b9..476b07820552 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -83,7 +83,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu agent: session.agent, sessionId, activate: false, - notify: false + notify: false, + visibilityWriteMayFail: true }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable @@ -105,14 +106,25 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu replacesSessionId?: string /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string + /** A restore's republish: a failed visibility write is logged and the tab still publishes, + * since a client drops every unpublished chat tab. */ + visibilityWriteMayFail?: boolean }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility( + const written = host.setSessionTabVisibility( input.sessionId, true, ...(input.tabId ? [input.tabId] : []) ) + await (input.visibilityWriteMayFail + ? written.catch((error) => + console.warn('[structured-agent-session] recording a restored chat tab failed', { + sessionId: input.sessionId, + error + }) + ) + : written) } const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 1d8105d07f81..80b8e4f3be65 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -356,7 +356,8 @@ describe('structured session cold restoration', () => { sessionId: 'restored-claude', agent: 'claude', activate: false, - notify: false + notify: false, + visibilityWriteMayFail: true }) const restored = await runtime.listMobileSessionTabs('id:workspace-1') diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 9cdb980684f8..0c6b392970d4 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -317,13 +317,10 @@ async function installOnJournal( : {}), onEventSinkError: ({ sessionId, error }) => deps.onError?.({ scope: `structured-agent-session-journal:${sessionId}`, error }), - onStartupReconcileFailure: (error) => + onLeaseReconcileFailure: (error) => deps.onError - ? deps.onError({ scope: 'structured-agent-session-startup-reconcile', error }) - : console.warn( - '[structured-agent-session] reconciling chat leases at startup failed', - error - ), + ? deps.onError({ scope: 'structured-agent-session-lease-reconcile', error }) + : console.warn('[structured-agent-session] reconciling chat leases failed', error), ...(deps.onSessionStatusChanged ? { onSessionStatusChanged: deps.onSessionStatusChanged } : {}), ...(deps.statusSink ? { statusSink: deps.statusSink } : {}), ...(deps.hasOpenDispatch ? { hasOpenDispatch: deps.hasOpenDispatch } : {}), diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 4b444eef70e1..8fecec36a2ce 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -5,8 +5,15 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import type * as FileTransactionLock from '../file-transaction-lock' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -18,6 +25,22 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +const lock = vi.hoisted(() => ({ failing: false })) + +vi.mock('../file-transaction-lock', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + withFileTransactionLock: (...args: Parameters) => + lock.failing + ? // What proper-lockfile throws once its retries give up. + Promise.reject( + Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) + ) + : actual.withFileTransactionLock(...args) + } +}) + let root: string beforeEach(async () => { @@ -25,13 +48,16 @@ beforeEach(async () => { }) afterEach(async () => { + lock.failing = false await stopStructuredAgentSessionRuntime().catch(() => undefined) vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) -/** A profile whose record store a newer Orca wrote, with one chat in it. */ -async function seedNewerStore() { +const PROMPT = 'add a retry' + +/** A profile's record store with one chat in it; one a newer Orca wrote opens read-only here. */ +async function seedStore(options: { newer: boolean; visible?: boolean }) { const storeDirectory = join(root, 'agent-sessions') const path = agentSessionStorePath(storeDirectory) const record = agentSessionRecordFixture() @@ -39,28 +65,61 @@ async function seedNewerStore() { await writeFile( path, JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + 1, + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + (options.newer ? 1 : 0), hostId: 'local', records: { [record.sessionId]: record }, operations: {}, retiredClaimKeys: [], - unusableRecords: {} + unusableRecords: {}, + ...(options.visible ? { visibleSessionIds: [record.sessionId] } : {}) }) ) - return { storeDirectory, path, sessionId: record.sessionId } + return { storeDirectory, path, record, sessionId: record.sessionId } } -function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { +/** The chat's history as the last run left it: one prompt, accepted. */ +async function seedHistory(record: AgentSessionRecord): Promise { + const fence = record.lease.runtimeFence + const database = JournalHostDatabase.open(root) + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor( + record, + attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: fence }) + ), + database + }) + await journal.appendSubmission({ + clientMessageId: 'client-1', + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, + fence, + handoverRecorded: true + }) + await journal.resolveDispatch({ + clientMessageId: 'client-1', + fence, + state: 'accepted', + providerIdentity: null + }) + await journal.close() + database.close() +} + +function startupRuntime( + onError?: (input: { scope: string; error: unknown }) => void, + afterInstall?: () => void +) { const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean ensureStructuredAgentSessionHost(): Promise refreshMobileSessionPtyRecords(): Promise | null> + mobileSessionTabsByWorktree: Map } internal.hasPersistedStructuredAgentSessionStore = () => true - internal.ensureStructuredAgentSessionHost = () => - ensureStructuredAgentSessionHost({ + internal.ensureStructuredAgentSessionHost = async () => { + const installed = await ensureStructuredAgentSessionHost({ stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -69,23 +128,26 @@ function startupRuntime(onError?: (input: { scope: string; error: unknown }) => resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), ...(onError ? { onError } : {}) }) + afterInstall?.() + return installed + } internal.refreshMobileSessionPtyRecords = async () => new Set() - return runtime + return { runtime, publishedTabs: () => internal.mobileSessionTabsByWorktree.get('workspace-1') } } it('finishes startup over records a newer Orca wrote, reports it, and writes nothing', async () => { - const { storeDirectory, path, sessionId } = await seedNewerStore() + const { storeDirectory, path, sessionId } = await seedStore({ newer: true }) const bytes = await readFile(path) const files = await readdir(storeDirectory) const onError = vi.fn() - const runtime = startupRuntime(onError) + const { runtime } = startupRuntime(onError) // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() expect(onError).toHaveBeenCalledOnce() expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-startup-reconcile', + scope: 'structured-agent-session-lease-reconcile', error: expect.objectContaining({ message: 'agent_session_legacy_required' }) }) expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') @@ -96,15 +158,81 @@ it('finishes startup over records a newer Orca wrote, reports it, and writes not // The desktop installs its host with no error sink, so the failure is logged rather than dropped. it('logs the failure when the host has no error sink', async () => { - await seedNewerStore() + await seedStore({ newer: true }) const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) await expect( - startupRuntime().prepareStructuredAgentSessionStartupRestoration() + startupRuntime().runtime.prepareStructuredAgentSessionStartupRestoration() ).resolves.toBeUndefined() expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] reconciling chat leases at startup failed', + '[structured-agent-session] reconciling chat leases failed', expect.objectContaining({ message: 'agent_session_legacy_required' }) ) }) + +// With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), +// which reads every chat whose tab was open at quit. That read must not wait on the lease bookkeeping. +describe('restoring the chat tabs open at quit', () => { + async function expectChatRestored( + runtime: OrcaRuntimeService, + publishedTabs: () => RuntimeMobileSessionTabsSnapshot | undefined, + sessionId: string + ) { + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + expect(publishedTabs()?.tabs.map((tab) => tab.id)).toEqual([`agent-session:${sessionId}`]) + const host = getStructuredAgentSessionHost() + expect(JSON.stringify((await host!.journalSnapshot(sessionId)).items)).toContain(PROMPT) + } + + /** The reconcile failure once, though startup and the read both ran it; the tab write per chat. */ + function expectReported( + onError: ReturnType, + warn: ReturnType, + sessionId: string, + failure: Record + ) { + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-lease-reconcile', + error: expect.objectContaining(failure) + }) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording a restored chat tab failed', + { sessionId, error: expect.objectContaining(failure) } + ) + } + + it('lists and reads a chat from records a newer Orca wrote, and writes nothing', async () => { + const { storeDirectory, path, record } = await seedStore({ newer: true, visible: true }) + await seedHistory(record) + const bytes = await readFile(path) + const files = await readdir(storeDirectory) + const onError = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, publishedTabs } = startupRuntime(onError) + + await expectChatRestored(runtime, publishedTabs, record.sessionId) + + expectReported(onError, warn, record.sessionId, { message: 'agent_session_legacy_required' }) + await stopStructuredAgentSessionRuntime() + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(storeDirectory)).toEqual(files) + }) + + it('lists and reads a chat while the record store lock keeps failing', async () => { + const { record } = await seedStore({ newer: false, visible: true }) + await seedHistory(record) + const onError = vi.fn() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // Free when the store opens, then held for good: every later write gives up. + const { runtime, publishedTabs } = startupRuntime(onError, () => { + lock.failing = true + }) + + await expectChatRestored(runtime, publishedTabs, record.sessionId) + + expectReported(onError, warn, record.sessionId, { code: 'ELOCKED' }) + lock.failing = false + }) +}) From b23eb280daa9317226941d24a883caeb895bf28b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:01:18 -0700 Subject: [PATCH 03/30] fix(native-chat): keep every record-store write off the startup chat read path Round-2 review found two more writes on the startup chat restore that could still fail it and put the app into "Session restore failed": republishing a /clear replacement recorded its tab visibility strictly, and resolving a chat's recovery rethrew its store error. The restore also paid one lock wait per tab and per batch of chats while the lock stayed held. The restore now derives tabs from state it already holds: - publishStructuredAgentSessionTab splits into the strict write and projectStructuredAgentSessionTab, which only updates the runtime's snapshot. The restore and /clear replacements only project: a saved tab index already lists every restored chat, and a /clear moves the tab in the same write that commits it. visibilityWriteMayFail is gone. - Chats a legacy profile restores that the index does not list are recorded in one best-effort transaction (store.showSessionTabs), so a failure leaves the index absent to seed again rather than partial. - The read restore's recovery resolution is caught and reported through onLeaseReconcileFailure, deduplicated with the reconcile's reports. - Once lease bookkeeping fails in a restore pass, the rest of that pass skips it, so a held lock costs one wait for the startup reconcile and one for the restore, however many chats are open. User actions (create, reveal, attach, send, the /clear commit) keep their strict writes. --- .../structured-agent-session-host-types.ts | 5 +- .../structured-agent-session-host.ts | 1 + ...ed-agent-session-readable-restorer.test.ts | 2 +- ...uctured-agent-session-restart-reconcile.ts | 39 +- ...ured-agent-session-restart-restore.test.ts | 7 +- ...tructured-agent-session-restart-restore.ts | 26 +- ...ent-session-restore-without-import.test.ts | 2 +- .../structured-agent-session-reveal.ts | 28 +- ...-session-startup-reconcile-failure.test.ts | 35 +- .../runtime/agent-session-record-store.ts | 8 +- src/main/runtime/agent-session-tab-table.ts | 12 + ...tore-structured-agent-session-tabs-once.ts | 65 ++-- ...runtime-structured-session-restore.test.ts | 31 +- .../rpc/methods/structured-agent-session.ts | 2 +- ...ed-agent-session-startup-reconcile.test.ts | 156 +------- ...-agent-session-startup-tab-restore.test.ts | 351 ++++++++++++++++++ ...structured-agent-session-tab-index-seed.ts | 35 ++ 17 files changed, 593 insertions(+), 212 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-startup-tab-restore.test.ts create mode 100644 src/main/runtime/structured-agent-session-tab-index-seed.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts index e2e8e07d4fb3..f69bc3fb06aa 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-types.ts @@ -120,8 +120,9 @@ export type StructuredAgentSessionHostDeps = { /** Whether an orchestration dispatch still owns this session's worker; absent answers no. */ hasOpenDispatch?: (record: AgentSessionRecord) => boolean onEventSinkError?: (input: { sessionId: string; error: unknown }) => void - /** A lease reconcile run for startup or a read that refused or threw, once per distinct failure. - * Startup and the read carry on: the next attach or send reconciles every lease again. */ + /** Lease bookkeeping run for startup or a read (the reconcile, or resolving a chat's recovery) + * that refused or threw, once per distinct failure. Startup and the read carry on: the next + * attach or send reconciles and resolves recovery again before it acts. */ onLeaseReconcileFailure?: (failure: unknown) => void /** Every status projection this host publishes. `replay` marks a re-projection of state the host * already knew (restore, an arriving subscriber) rather than a fresh journal edge. */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 78bc7de155ef..406f105a7c75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -215,6 +215,7 @@ export class StructuredAgentSessionHost { listSessionTabs = () => sessionTabs.listStructuredAgentSessionTabs(this.sessions) getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) + showSessionTabs = (sessionIds: readonly string[]) => this.deps.store.showSessionTabs(sessionIds) setSessionTabVisibility = async ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index 240882f17564..09cab618ab9a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -40,7 +40,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { }, supportsRecord: () => true, reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index c9cba45aa59d..be2d0b6c1513 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -45,32 +45,51 @@ export function createRestartReconciler(deps: { } } +/** Reports each distinct failure once, until `clear` says the bookkeeping settled again. Every read + * re-runs a failing reconcile, so without this a failing store would log on every chat list. */ +export type ReaderBookkeepingFailures = { + report: (failure: unknown) => void + clear: () => void +} + +export function reportEachFailureOnce( + onFailure: ((failure: unknown) => void) | undefined +): ReaderBookkeepingFailures { + let reported: string | null = null + return { + report: (failure) => { + const key = failureKey(failure) + if (key !== reported) { + reported = key + onFailure?.(failure) + } + }, + clear: () => { + reported = null + } + } +} + /** The reconcile a reader runs, at startup and before each restored read: it never throws, since * an unreconciled lease grants no writer and the next send reconciles again before it acts. - * Answers whether every lease is settled; a failure is reported once until a reconcile settles. */ + * Answers whether every lease is settled. */ export function createReaderReconcile( reconcile: (sessionId: string) => Promise, - onFailure: ((failure: unknown) => void) | undefined + failures: ReaderBookkeepingFailures ): (sessionId: string) => Promise { - let reported: string | null = null return async (sessionId) => { let failure: unknown try { const refusal = await reconcile(sessionId) if (!refusal) { - reported = null + failures.clear() return true } failure = refusal } catch (error) { failure = error } - // Every read re-runs a failing reconcile, so each distinct failure is reported once. - const key = failureKey(failure) - if (key !== reported) { - reported = key - onFailure?.(failure) - } + failures.report(failure) return false } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 3bf000f9e409..c7d6b21ea1b0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -63,7 +63,7 @@ describe('restart journal restoration', () => { openDeps: NO_OPEN_DEPS, records, reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined @@ -104,7 +104,7 @@ describe('restart journal restoration', () => { openDeps: NO_OPEN_DEPS, records, reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => false, onReadable: () => undefined @@ -153,6 +153,7 @@ describe('restart journal restoration', () => { reconcile: async () => true, resolveRecovery: async () => { calls.push('resolveRecovery') + return true }, serialize: async (_sessionId, task) => task(), hasSession: () => false, @@ -175,7 +176,7 @@ describe('restart journal restoration', () => { records: [{ sessionId: 'session-1' } as AgentSessionRecord], openDeps: NO_OPEN_DEPS, reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: () => true, onReadable: () => undefined diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index 02561594caa8..facc67a86a92 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -27,9 +27,11 @@ export type StructuredAgentSessionReadRestoreDeps = { openDeps: StructuredAgentSessionConversationOpenDeps & { store: Pick } - /** Whether every lease is settled. Never throws: a read grants no writer, so bookkeeping can't block it. */ + // Lease bookkeeping. Neither throws: a read grants no writer, so bookkeeping must not block it. + /** Whether every lease is settled. */ reconcile: (sessionId: string) => Promise - resolveRecovery: (sessionId: string) => Promise + /** False when its store write failed; the next attach or send resolves it again. */ + resolveRecovery: (sessionId: string) => Promise serialize: (sessionId: string, task: () => Promise) => Promise hasSession: (sessionId: string) => boolean onReadable: ( @@ -41,12 +43,10 @@ export type StructuredAgentSessionReadRestoreDeps = { /** One session's share of the restart restore. Startup maps this over every supported record. */ async function restoreOneStructuredAgentSessionRead( input: StructuredAgentSessionReadRestoreDeps, - sessionId: string + sessionId: string, + settleLeases: (sessionId: string) => Promise ): Promise { - if (await input.reconcile(sessionId)) { - // A session latched in recovery exits here at startup, without waiting for a client. - await input.resolveRecovery(sessionId) - } + await settleLeases(sessionId) await input.serialize(sessionId, () => restoreOneStructuredAgentSessionReadUnderSerialize(input, sessionId) ) @@ -72,9 +72,19 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( export async function restoreStructuredAgentSessionsOnRestart( input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } ): Promise { + // Once bookkeeping fails in this pass, trying again per chat only waits on the same store again. + let bookkeepingFailed = false + const settleLeases = async (sessionId: string): Promise => { + if (bookkeepingFailed) { + return + } + // A session latched in recovery exits here at startup, without waiting for a client. + const settled = (await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId)) + bookkeepingFailed ||= !settled + } await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. await yieldToEventLoop() - await restoreOneStructuredAgentSessionRead(input, sessionId) + await restoreOneStructuredAgentSessionRead(input, sessionId, settleLeases) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 1308dbfe4a83..3290ba05f0de 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -233,7 +233,7 @@ async function restore(sessionIds: readonly string[]) { openDeps: deps, records: sessionIds.map(recordFor), reconcile: async () => true, - resolveRecovery: async () => undefined, + resolveRecovery: async () => true, serialize: async (_sessionId, task) => task(), hasSession: (sessionId) => sessions.has(sessionId), onReadable: (sessionId, opened) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts index 96b0abd2b113..262c9e18d3c6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-reveal.ts @@ -15,7 +15,10 @@ import { agentSessionRefusalError } from '../../../shared/agent-session-wire-ref import { adapterSupportsRecord } from './structured-agent-session-provider-support' import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate' -import { createReaderReconcile } from './structured-agent-session-restart-reconcile' +import { + createReaderReconcile, + reportEachFailureOnce +} from './structured-agent-session-restart-reconcile' import type { StructuredAgentSessionHostDeps, StructuredAgentSessionReveal @@ -54,23 +57,36 @@ export async function revealStructuredAgentSession( } /** The host's startup readable-restore sweep: reconcile, resolve, then open each chat's journal. - * Its reconcile is a reader's, which never fails a read or startup; startup runs the same one. */ + * Its lease bookkeeping is a reader's, which never fails a read or startup; startup shares it. */ export function createStructuredAgentSessionHostRestore( deps: StructuredAgentSessionHostDeps, wiring: Omit< ConstructorParameters[0], - 'openDeps' | 'supportsRecord' | 'reconcile' - > & { reconcileLeases: (sessionId: string) => Promise } + 'openDeps' | 'supportsRecord' | 'reconcile' | 'resolveRecovery' + > & { + reconcileLeases: (sessionId: string) => Promise + resolveRecovery: (sessionId: string) => Promise + } ): { reconcileRestartLeases: () => Promise restoreReadableSessions: (sessionIds?: readonly string[]) => Promise } { - const { reconcileLeases, ...rest } = wiring - const reconcile = createReaderReconcile(reconcileLeases, deps.onLeaseReconcileFailure) + const { reconcileLeases, resolveRecovery, ...rest } = wiring + const failures = reportEachFailureOnce(deps.onLeaseReconcileFailure) + const reconcile = createReaderReconcile(reconcileLeases, failures) const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: deps, supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record), reconcile, + // The next attach or send resolves recovery again, strictly, before it acts. + resolveRecovery: (sessionId) => + resolveRecovery(sessionId).then( + () => true, + (error: unknown) => { + failures.report(error) + return false + } + ), ...rest }) const gate = new StructuredAgentSessionRestartRestoreGate() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 5e1c8a95868c..8033843cd41e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -11,7 +11,10 @@ import { AGENT_SESSION_STORE_SCHEMA_VERSION, agentSessionStorePath } from '../../runtime/agent-session-record-store-file' -import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + StructuredAgentSessionHost, + type StructuredAgentSessionHostDeps +} from './structured-agent-session-host' import { adapter, attach, @@ -53,7 +56,12 @@ afterEach(async () => { }) /** A host with one chat, relaunched over a copy of its files; `rewrite` edits the copied store. */ -async function relaunch(rewrite?: (json: Record) => void) { +async function relaunch( + rewrite?: (json: Record) => void, + probeOwner: StructuredAgentSessionHostDeps['probeOwner'] = async () => ({ + outcome: 'pid-absent' + }) +) { const dying = hostTestState() await attach() // An empty renewal queues behind every record write, so they are on disk. @@ -80,7 +88,10 @@ async function relaunch(rewrite?: (json: Record) => void) { journalDatabase: openTestJournalHostDatabase(relaunched), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', - probeOwner: async () => ({ outcome: 'pid-absent' }), + probeOwner, + stopOwnerProcess: () => { + throw new Error('a read must not stop an owner') + }, now: () => NOW, onLeaseReconcileFailure }) @@ -168,3 +179,21 @@ it('restores a chat for reading from a store a newer Orca wrote', async () => { expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() expect(await readFile(path)).toEqual(bytes) }) + +// A chat whose owner could not be proven gone is left recovering; resolving that is the writer's. +it('restores a chat for reading when resolving its recovery cannot write the store', async () => { + const { host, store, onLeaseReconcileFailure } = await relaunch(undefined, async () => ({ + outcome: 'indeterminate', + reason: 'probe' + })) + await host.reconcileRestartLeases() + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + lock.failing = true + + await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() + + expect(host.hasSession(SESSION)).toBe(true) + expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) +}) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 925fa38d8d18..603b5ed02faf 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -66,7 +66,7 @@ import { agentSessionStorePath, type AgentSessionStoreState } from './agent-session-record-store-file' -import { setAgentSessionTabVisibility } from './agent-session-tab-table' +import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' import { AgentSessionStoreTransactionQueue, @@ -146,6 +146,12 @@ export class AgentSessionRecordStore { return this.transact(() => setAgentSessionTabVisibility(this.state, sessionId, visible, tabId)) } + /** Shows each session that still has a record, in one write: an index written part way would + * read as complete at the next launch and drop the rest. */ + showSessionTabs(sessionIds: readonly string[]): Promise { + return this.transact(() => showAgentSessionTabs(this.state, sessionIds)) + } + listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { const scope = agentSessionScopeKey(location) return this.listRecords().filter((record) => agentSessionScopeKey(record.location) === scope) diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 6022aac8bf2b..840356cf0cf0 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -131,6 +131,18 @@ export function setAgentSessionTabVisibility( } } +/** Shows each session that still has a record. */ +export function showAgentSessionTabs( + state: AgentSessionStoreState, + sessionIds: readonly string[] +): void { + for (const sessionId of sessionIds) { + if (state.records.has(sessionId)) { + setAgentSessionTabVisibility(state, sessionId, true) + } + } +} + export type PersistedAgentSessionTab = { tabId: string; sessionId: string } export function serializeAgentSessionTabTable(table: AgentSessionTabTable): { diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 476b07820552..1eea896b5dce 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -5,6 +5,7 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire import { replaceConversationInSnapshot } from './structured-conversation-tab-replacement' import type { ConversationReplacement } from '../native-chat/agent-session-wire/structured-conversation-command' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' +import { seedStructuredAgentSessionTabIndex } from './structured-agent-session-tab-index-seed' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { RuntimeMobileSessionAgentTab, @@ -28,7 +29,9 @@ import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import type { PtyProcessInspection } from '../providers/pty-process-inspection' export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRuntimeWithGetStructuredAgentSessionCreateSupport { - async replaceStructuredAgentSessionTab(replacement: ConversationReplacement): Promise { + /** Projects only: a replacement's chat already has its tab in the store, which the /clear commit + * moved in the same write. */ + replaceStructuredAgentSessionTab(replacement: ConversationReplacement): void { const prior = this.mobileSessionTabsByWorktree.get(replacement.workspaceId) const next = prior ? replaceConversationInSnapshot(prior, replacement) : null if (next && next !== prior) { @@ -39,7 +42,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu (tab) => tab.type === 'agent-session' && tab.sessionId === replacement.sessionId ) ) { - await this.publishStructuredAgentSessionTab({ + this.projectStructuredAgentSessionTab({ ...replacement, replacesSessionId: replacement.sourceSessionId, activate: false @@ -57,9 +60,8 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - await host?.restoreReadableSessions( - persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds - ) + const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds + await host?.restoreReadableSessions(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { allowAttachedWindow: true, @@ -67,25 +69,27 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }) } this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession() - for (const replacement of host?.conversationReplacements?.() ?? []) { - await this.replaceStructuredAgentSessionTab(replacement) - } - for (const session of host?.listSessionTabs() ?? []) { + const restored = (host?.listSessionTabs() ?? []).flatMap((session) => { if (session.agent !== 'codex' && session.agent !== 'claude') { - continue + return [] } let sessionId = session.sessionId while (sessionId.startsWith('agent-session:')) { sessionId = sessionId.slice('agent-session:'.length) } - await this.publishStructuredAgentSessionTab({ - ...session, - agent: session.agent, - sessionId, - activate: false, - notify: false, - visibilityWriteMayFail: true - }) + return [{ ...session, agent: session.agent, sessionId }] + }) + await seedStructuredAgentSessionTabIndex( + host, + targets, + restored.map((session) => session.sessionId) + ) + // The store already lists every tab projected here, so the restore writes nothing else. + for (const replacement of host?.conversationReplacements?.() ?? []) { + this.replaceStructuredAgentSessionTab(replacement) + } + for (const session of restored) { + this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable // No host means no one can say which chats exist; with none on disk, empty is the answer. @@ -106,26 +110,27 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu replacesSessionId?: string /** The host tab id a create reserved; a session that already has a tab keeps its own. */ tabId?: string - /** A restore's republish: a failed visibility write is logged and the tab still publishes, - * since a client drops every unpublished chat tab. */ - visibilityWriteMayFail?: boolean }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - const written = host.setSessionTabVisibility( + await host.setSessionTabVisibility( input.sessionId, true, ...(input.tabId ? [input.tabId] : []) ) - await (input.visibilityWriteMayFail - ? written.catch((error) => - console.warn('[structured-agent-session] recording a restored chat tab failed', { - sessionId: input.sessionId, - error - }) - ) - : written) } + this.projectStructuredAgentSessionTab(input) + } + + /** The runtime's own snapshot of a chat tab. Records nothing: the caller owns the store write. */ + projectStructuredAgentSessionTab(input: { + workspaceId: string + sessionId: string + agent: 'claude' | 'codex' + activate: boolean + notify?: boolean + replacesSessionId?: string + }): void { const existing = this.mobileSessionTabsByWorktree.get(input.workspaceId) const id = `agent-session:${input.sessionId}` if (existing?.tabs.some((tab) => tab.id === id)) { diff --git a/src/main/runtime/orca-runtime-structured-session-restore.test.ts b/src/main/runtime/orca-runtime-structured-session-restore.test.ts index 80b8e4f3be65..929ee82b6c89 100644 --- a/src/main/runtime/orca-runtime-structured-session-restore.test.ts +++ b/src/main/runtime/orca-runtime-structured-session-restore.test.ts @@ -324,7 +324,7 @@ describe('structured session cold restoration', () => { it('publishes restored Claude tabs with the Claude title', async () => { const runtime = new OrcaRuntimeService() - const publish = vi.spyOn(runtime, 'publishStructuredAgentSessionTab') + const project = vi.spyOn(runtime, 'projectStructuredAgentSessionTab') const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean getKnownWorkspaceSessionWorktreeIds(): Set @@ -351,13 +351,12 @@ describe('structured session cold restoration', () => { await runtime.restoreStructuredAgentSessionTabs() - expect(publish).toHaveBeenCalledWith({ + expect(project).toHaveBeenCalledWith({ workspaceId: 'workspace-1', sessionId: 'restored-claude', agent: 'claude', activate: false, - notify: false, - visibilityWriteMayFail: true + notify: false }) const restored = await runtime.listMobileSessionTabs('id:workspace-1') @@ -373,6 +372,30 @@ describe('structured session cold restoration', () => { ) }) + // The /clear commit moved the tab in the store already, so replacing it only projects. + it('replaces a cleared conversation tab without a store write', async () => { + const runtime = new OrcaRuntimeService() + const setSessionTabVisibility = vi.fn(async () => undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: replacing a tab reaches the host only through setSessionTabVisibility, which must stay uncalled. + setStructuredAgentSessionHost({ setSessionTabVisibility } as never) + + runtime.replaceStructuredAgentSessionTab({ + sourceSessionId: 'cleared-session', + sessionId: 'replacement-session', + workspaceId: 'workspace-1', + agent: 'codex' + }) + + const snapshot = await runtime.listMobileSessionTabs('id:workspace-1') + expect(snapshot.tabs).toEqual([ + expect.objectContaining({ + id: 'agent-session:replacement-session', + replacesSessionId: 'cleared-session' + }) + ]) + expect(setSessionTabVisibility).not.toHaveBeenCalled() + }) + it('commits the host close when the renderer already removed the structured tab', async () => { const runtime = new OrcaRuntimeService() runtime.setNotifier({ diff --git a/src/main/runtime/rpc/methods/structured-agent-session.ts b/src/main/runtime/rpc/methods/structured-agent-session.ts index 534d49677793..463772d33375 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session.ts @@ -123,7 +123,7 @@ export const STRUCTURED_AGENT_SESSION_METHODS = [ .conversationReplacements() .find((entry) => entry.sourceSessionId === params.envelope.sessionId) if (replacement) { - await ctx.runtime.replaceStructuredAgentSessionTab(replacement) + ctx.runtime.replaceStructuredAgentSessionTab(replacement) } await host.close(params.envelope.sessionId) } diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 8fecec36a2ce..33b26f7569bb 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -5,15 +5,8 @@ import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import type * as FileTransactionLock from '../file-transaction-lock' -import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' -import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -25,22 +18,6 @@ import { stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -const lock = vi.hoisted(() => ({ failing: false })) - -vi.mock('../file-transaction-lock', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - withFileTransactionLock: (...args: Parameters) => - lock.failing - ? // What proper-lockfile throws once its retries give up. - Promise.reject( - Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) - ) - : actual.withFileTransactionLock(...args) - } -}) - let root: string beforeEach(async () => { @@ -48,16 +25,13 @@ beforeEach(async () => { }) afterEach(async () => { - lock.failing = false await stopStructuredAgentSessionRuntime().catch(() => undefined) vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) -const PROMPT = 'add a retry' - -/** A profile's record store with one chat in it; one a newer Orca wrote opens read-only here. */ -async function seedStore(options: { newer: boolean; visible?: boolean }) { +/** A profile whose record store a newer Orca wrote, with one chat in it. */ +async function seedNewerStore() { const storeDirectory = join(root, 'agent-sessions') const path = agentSessionStorePath(storeDirectory) const record = agentSessionRecordFixture() @@ -65,61 +39,28 @@ async function seedStore(options: { newer: boolean; visible?: boolean }) { await writeFile( path, JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + (options.newer ? 1 : 0), + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + 1, hostId: 'local', records: { [record.sessionId]: record }, operations: {}, retiredClaimKeys: [], - unusableRecords: {}, - ...(options.visible ? { visibleSessionIds: [record.sessionId] } : {}) + unusableRecords: {} }) ) - return { storeDirectory, path, record, sessionId: record.sessionId } + return { storeDirectory, path, sessionId: record.sessionId } } -/** The chat's history as the last run left it: one prompt, accepted. */ -async function seedHistory(record: AgentSessionRecord): Promise { - const fence = record.lease.runtimeFence - const database = JournalHostDatabase.open(root) - const journal = await openAgentSessionJournal({ - identity: journalIdentityFor( - record, - attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: fence }) - ), - database - }) - await journal.appendSubmission({ - clientMessageId: 'client-1', - payloadFingerprint: 'fp-1', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, - fence, - handoverRecorded: true - }) - await journal.resolveDispatch({ - clientMessageId: 'client-1', - fence, - state: 'accepted', - providerIdentity: null - }) - await journal.close() - database.close() -} - -function startupRuntime( - onError?: (input: { scope: string; error: unknown }) => void, - afterInstall?: () => void -) { +function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { const runtime = new OrcaRuntimeService() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. const internal = runtime as unknown as { hasPersistedStructuredAgentSessionStore(): boolean ensureStructuredAgentSessionHost(): Promise refreshMobileSessionPtyRecords(): Promise | null> - mobileSessionTabsByWorktree: Map } internal.hasPersistedStructuredAgentSessionStore = () => true - internal.ensureStructuredAgentSessionHost = async () => { - const installed = await ensureStructuredAgentSessionHost({ + internal.ensureStructuredAgentSessionHost = () => + ensureStructuredAgentSessionHost({ stateDirectory: root, hostId: 'local', claimKeyId: 'key-1', @@ -128,19 +69,16 @@ function startupRuntime( resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), ...(onError ? { onError } : {}) }) - afterInstall?.() - return installed - } internal.refreshMobileSessionPtyRecords = async () => new Set() - return { runtime, publishedTabs: () => internal.mobileSessionTabsByWorktree.get('workspace-1') } + return runtime } it('finishes startup over records a newer Orca wrote, reports it, and writes nothing', async () => { - const { storeDirectory, path, sessionId } = await seedStore({ newer: true }) + const { storeDirectory, path, sessionId } = await seedNewerStore() const bytes = await readFile(path) const files = await readdir(storeDirectory) const onError = vi.fn() - const { runtime } = startupRuntime(onError) + const runtime = startupRuntime(onError) // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() @@ -158,11 +96,11 @@ it('finishes startup over records a newer Orca wrote, reports it, and writes not // The desktop installs its host with no error sink, so the failure is logged rather than dropped. it('logs the failure when the host has no error sink', async () => { - await seedStore({ newer: true }) + await seedNewerStore() const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) await expect( - startupRuntime().runtime.prepareStructuredAgentSessionStartupRestoration() + startupRuntime().prepareStructuredAgentSessionStartupRestoration() ).resolves.toBeUndefined() expect(warn).toHaveBeenCalledWith( @@ -170,69 +108,3 @@ it('logs the failure when the host has no error sink', async () => { expect.objectContaining({ message: 'agent_session_legacy_required' }) ) }) - -// With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), -// which reads every chat whose tab was open at quit. That read must not wait on the lease bookkeeping. -describe('restoring the chat tabs open at quit', () => { - async function expectChatRestored( - runtime: OrcaRuntimeService, - publishedTabs: () => RuntimeMobileSessionTabsSnapshot | undefined, - sessionId: string - ) { - await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() - expect(publishedTabs()?.tabs.map((tab) => tab.id)).toEqual([`agent-session:${sessionId}`]) - const host = getStructuredAgentSessionHost() - expect(JSON.stringify((await host!.journalSnapshot(sessionId)).items)).toContain(PROMPT) - } - - /** The reconcile failure once, though startup and the read both ran it; the tab write per chat. */ - function expectReported( - onError: ReturnType, - warn: ReturnType, - sessionId: string, - failure: Record - ) { - expect(onError).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining(failure) - }) - expect(warn).toHaveBeenCalledWith( - '[structured-agent-session] recording a restored chat tab failed', - { sessionId, error: expect.objectContaining(failure) } - ) - } - - it('lists and reads a chat from records a newer Orca wrote, and writes nothing', async () => { - const { storeDirectory, path, record } = await seedStore({ newer: true, visible: true }) - await seedHistory(record) - const bytes = await readFile(path) - const files = await readdir(storeDirectory) - const onError = vi.fn() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, publishedTabs } = startupRuntime(onError) - - await expectChatRestored(runtime, publishedTabs, record.sessionId) - - expectReported(onError, warn, record.sessionId, { message: 'agent_session_legacy_required' }) - await stopStructuredAgentSessionRuntime() - expect(await readFile(path)).toEqual(bytes) - expect(await readdir(storeDirectory)).toEqual(files) - }) - - it('lists and reads a chat while the record store lock keeps failing', async () => { - const { record } = await seedStore({ newer: false, visible: true }) - await seedHistory(record) - const onError = vi.fn() - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - // Free when the store opens, then held for good: every later write gives up. - const { runtime, publishedTabs } = startupRuntime(onError, () => { - lock.failing = true - }) - - await expectChatRestored(runtime, publishedTabs, record.sessionId) - - expectReported(onError, warn, record.sessionId, { code: 'ELOCKED' }) - lock.failing = false - }) -}) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts new file mode 100644 index 000000000000..77797a5f531b --- /dev/null +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -0,0 +1,351 @@ +// With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), +// which reads every chat whose tab was open at quit. That read must not wait on record-store +// bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs +// a bounded number of lock waits, not one per chat. + +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import type * as FileTransactionLock from '../file-transaction-lock' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' +import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath +} from './agent-session-record-store-file' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +// `failing` refuses every take; `grants` lets that many more through, then refuses. +const lock = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) + +vi.mock('../file-transaction-lock', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + withFileTransactionLock: (...args: Parameters) => { + if (lock.failing || lock.grants <= 0) { + lock.refused += 1 + // What proper-lockfile throws once its retries give up, about 3 s later. + return Promise.reject( + Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) + ) + } + lock.grants -= 1 + return actual.withFileTransactionLock(...args) + } + } +}) + +const PROMPT = 'add a retry' +const CHAT_A = 'chat-a-0001' +const CHAT_B = 'chat-b-0002' +const CLEARED = 'chat-s-0003' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-startup-tab-restore-')) +}) + +afterEach(async () => { + Object.assign(lock, { failing: false, grants: Infinity, refused: 0 }) + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** A released chat, so no startup probe looks for a live owner. */ +function chatRecord( + sessionId: string, + options: { codex?: boolean; clearedInto?: string } = {} +): AgentSessionRecord { + const record = agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released' + }) + ) + const codex = options.codex + ? { + provider: 'codex' as const, + providerHandleChain: record.providerHandleChain.map((link) => ({ + ...link, + handle: { provider: 'codex' as const, threadId: `thread-${sessionId}` } + })), + accountHome: { variable: 'CODEX_HOME' as const, path: join(root, 'codex-home') } + } + : {} + const clear = options.clearedInto + ? { + conversationCommand: { + command: 'clear' as const, + state: 'completed' as const, + phase: 'committed' as const, + operationId: `clear-${sessionId}`, + callerKey: 'client-1', + replacementSessionId: options.clearedInto + } + } + : {} + return { ...record, ...codex, ...clear } +} + +/** Writes the record store; `visible` is the saved tab index, absent on a legacy profile. */ +async function seedStore( + records: AgentSessionRecord[], + options: { newer?: boolean; visible?: string[] } = {} +) { + const storeDirectory = join(root, 'agent-sessions') + const path = agentSessionStorePath(storeDirectory) + await mkdir(storeDirectory, { recursive: true }) + await writeFile( + path, + JSON.stringify({ + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + (options.newer ? 1 : 0), + hostId: 'local', + records: Object.fromEntries(records.map((record) => [record.sessionId, record])), + operations: {}, + retiredClaimKeys: [], + unusableRecords: {}, + ...(options.visible ? { visibleSessionIds: options.visible } : {}) + }) + ) + return { storeDirectory, path } +} + +/** Each chat's history as the last run left it: one prompt, accepted, so nothing is left to send. */ +async function seedHistory(records: AgentSessionRecord[]): Promise { + const database = JournalHostDatabase.open(root) + for (const record of records) { + const fence = record.lease.runtimeFence + const journal = await openAgentSessionJournal({ + identity: journalIdentityFor( + record, + attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: fence }) + ), + database + }) + await journal.appendSubmission({ + clientMessageId: 'client-1', + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, + fence, + handoverRecorded: true + }) + await journal.resolveDispatch({ + clientMessageId: 'client-1', + fence, + state: 'accepted', + providerIdentity: null + }) + await journal.close() + } + database.close() +} + +function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { + const onError = vi.fn() + 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`, stubs the PTY daemon and gives it a profile. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + mobileSessionTabsByWorktree: Map + store: { getWorkspaceSession: () => unknown } + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = async () => { + const installed = await ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + onError + }) + options.afterInstall?.() + return installed + } + internal.refreshMobileSessionPtyRecords = async () => new Set() + // A legacy profile's saved chat tabs, which a store with no tab index restores from. + internal.store = { + getWorkspaceSession: () => ({ + activeRepoId: null, + activeWorktreeId: 'workspace-1', + activeTabId: null, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabs: { + 'workspace-1': (options.profileChats ?? []).map((sessionId, index) => ({ + id: `agent-session:${sessionId}`, + entityId: sessionId, + groupId: 'group-1', + worktreeId: 'workspace-1', + contentType: 'agent-session', + label: 'Codex Chat', + customLabel: null, + color: null, + sortOrder: index, + createdAt: 1 + })) + } + }) + } + return { + runtime, + onError, + published: () => internal.mobileSessionTabsByWorktree.get('workspace-1')?.tabs ?? [] + } +} + +async function expectHistory(sessionId: string): Promise { + const host = getStructuredAgentSessionHost() + expect(JSON.stringify((await host!.journalSnapshot(sessionId)).items)).toContain(PROMPT) +} + +function spyOnTabWrites() { + return { + visibility: vi.spyOn(AgentSessionRecordStore.prototype, 'setSessionTabVisibility'), + seed: vi.spyOn(AgentSessionRecordStore.prototype, 'showSessionTabs') + } +} + +async function tabIndexOnDisk(path: string): Promise { + return JSON.parse(await readFile(path, 'utf-8')).visibleSessionIds +} + +describe('restoring the chat tabs open at quit', () => { + it.each([ + { store: 'records a newer Orca wrote', newer: true, lockFails: false }, + { store: 'a lock that keeps failing', newer: false, lockFails: true } + ])('lists and reads every chat from $store, and writes nothing', async ({ newer, lockFails }) => { + const records = [ + chatRecord(CHAT_A), + chatRecord(CHAT_B), + chatRecord(CLEARED, { clearedInto: CHAT_A }) + ] + const { storeDirectory, path } = await seedStore(records, { newer, visible: [CHAT_A, CHAT_B] }) + await seedHistory(records.slice(0, 2)) + const bytes = await readFile(path) + const files = await readdir(storeDirectory) + const writes = spyOnTabWrites() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // Free when the store opens, then held for good. + const { runtime, onError, published } = startupRuntime({ + afterInstall: () => { + lock.failing = lockFails + } + }) + + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${CHAT_A}`, + `agent-session:${CHAT_B}` + ]) + expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) + await expectHistory(CHAT_A) + await expectHistory(CHAT_B) + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-lease-reconcile', + error: expect.objectContaining( + newer ? { message: 'agent_session_legacy_required' } : { code: 'ELOCKED' } + ) + }) + expect(writes.visibility).not.toHaveBeenCalled() + expect(writes.seed).not.toHaveBeenCalled() + await stopStructuredAgentSessionRuntime() + if (newer) { + expect(await readFile(path)).toEqual(bytes) + expect(await readdir(storeDirectory)).toEqual(files) + } + }) + + it('costs two lock waits in all while the lock stays held, however many chats are open', async () => { + const chats = Array.from({ length: 8 }, (_, index) => `chat-${index}-000${index}`) + const records = chats.map((sessionId) => chatRecord(sessionId)) + await seedStore(records, { visible: chats }) + await seedHistory(records) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + afterInstall: () => { + lock.failing = true + } + }) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + await runtime.restoreStructuredAgentSessionTabs() + + expect(published()).toHaveLength(8) + // One for the startup reconcile, one for the restore's; the restore writes no tab. + expect(lock.refused).toBe(2) + }) + + describe('on a legacy profile, with no tab index yet', () => { + const legacyChats = () => [ + chatRecord(CHAT_A, { codex: true }), + chatRecord(CHAT_B, { codex: true }) + ] + + it('records every restored chat in one write', async () => { + const records = legacyChats() + const { path } = await seedStore(records) + await seedHistory(records) + const writes = spyOnTabWrites() + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + await runtime.prepareStructuredAgentSessionStartupRestoration() + // One more take, then held: a seed written chat by chat would stop part way. + lock.grants = 1 + + await runtime.restoreStructuredAgentSessionTabs() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${CHAT_A}`, + `agent-session:${CHAT_B}` + ]) + expect(await tabIndexOnDisk(path)).toEqual([CHAT_A, CHAT_B]) + expect(writes.seed).toHaveBeenCalledOnce() + expect(writes.visibility).not.toHaveBeenCalled() + }) + + it('still lists the chats when that write fails, and leaves the index to seed again', async () => { + const records = legacyChats() + const { path } = await seedStore(records) + await seedHistory(records) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) + await runtime.prepareStructuredAgentSessionStartupRestoration() + lock.failing = true + + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + + expect(published()).toHaveLength(2) + await expectHistory(CHAT_A) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording restored chat tabs failed', + { sessionIds: [CHAT_A, CHAT_B], error: expect.objectContaining({ code: 'ELOCKED' }) } + ) + expect(await tabIndexOnDisk(path)).toBeUndefined() + }) + }) +}) diff --git a/src/main/runtime/structured-agent-session-tab-index-seed.ts b/src/main/runtime/structured-agent-session-tab-index-seed.ts new file mode 100644 index 000000000000..181d67128a1f --- /dev/null +++ b/src/main/runtime/structured-agent-session-tab-index-seed.ts @@ -0,0 +1,35 @@ +// A profile saved before the store kept a tab index restores its chats from the profile's own tabs. +// Those chats are recorded once, all together: an index written part way would read as complete at +// the next launch and drop the rest. With an index present, every restored chat is already listed. + +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' + +type TabIndexHost = Pick< + StructuredAgentSessionHost, + 'getPersistedVisibleSessionTabIndex' | 'showSessionTabs' +> + +/** Best-effort: the tabs publish either way, and an index still absent is seeded again next launch. */ +export async function seedStructuredAgentSessionTabIndex( + host: Partial | null | undefined, + targets: readonly string[], + restored: readonly string[] +): Promise { + if (!host?.getPersistedVisibleSessionTabIndex || !host.showSessionTabs) { + return + } + const listed = new Set(host.getPersistedVisibleSessionTabIndex().sessionIds) + const opened = new Set(restored) + // In the restore's own order, so the seeded tabs keep the order the profile gave them. + const unlisted = [...new Set([...targets, ...restored])].filter( + (sessionId) => opened.has(sessionId) && !listed.has(sessionId) + ) + if (unlisted.length > 0) { + await host.showSessionTabs(unlisted).catch((error: unknown) => + console.warn('[structured-agent-session] recording restored chat tabs failed', { + sessionIds: unlisted, + error + }) + ) + } +} From 3c3544190d2efc475ed845e93d722d9e5bdcdecb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:15:11 -0700 Subject: [PATCH 04/30] test: open, seed and read the agent-session record store through one harness Tests that open the durable agent-session record store, seed it, or read back what it persisted now go through agent-session-record-store-test-harness.ts instead of calling AgentSessionRecordStore.open or touching agent-sessions.json themselves. A later change that moves the store into the chat database then changes the harness instead of every test. No production code changes. Tests whose subject is the JSON file itself (its .bak recovery, salvage, schema versions, permissions, and what older builds read back) keep reading and writing the file directly; the storage move rewrites or deletes them. --- .../tasks/agent-launch-mobile-replay.test.ts | 5 +- .../mobile-agent-launch-architecture.test.ts | 9 +- .../claude-api-retry-idle-sweep.test.ts | 7 +- .../codex-structured-question-order.test.ts | 5 +- ...-agent-session-accept-then-deliver.test.ts | 3 +- ...-agent-session-acquisition-options.test.ts | 51 ++++------- ...tured-agent-session-adopted-import.test.ts | 7 +- ...ured-agent-session-append-delivery.test.ts | 5 +- ...-agent-session-claude-compact-stop.test.ts | 5 +- ...-agent-session-claude-echo-working.test.ts | 5 +- ...-agent-session-claude-option-queue.test.ts | 5 +- ...d-agent-session-claude-queued-stop.test.ts | 5 +- ...red-agent-session-claude-root-exit.test.ts | 4 +- ...tured-agent-session-codex-stop-row.test.ts | 7 +- ...ed-agent-session-conversation-stop.test.ts | 5 +- ...ured-agent-session-crash-mid-start.test.ts | 8 +- ...tured-agent-session-crash-turn-end.test.ts | 25 ++---- ...ession-failed-create-owner-verdict.test.ts | 5 +- ...session-failed-create-sink-release.test.ts | 5 +- ...tured-agent-session-grouped-prompt.test.ts | 5 +- ...ed-agent-session-host-test-abandon.test.ts | 7 +- ...uctured-agent-session-host-test-harness.ts | 5 +- .../structured-agent-session-host.test.ts | 5 +- ...ured-agent-session-import-mismatch.test.ts | 7 +- ...ured-agent-session-late-settlement.test.ts | 5 +- ...ctured-agent-session-lease-renewer.test.ts | 5 +- ...gent-session-legacy-handoff-record.test.ts | 7 +- ...ssion-main-agent-working-agreement.test.ts | 5 +- ...ed-agent-session-option-settlement.test.ts | 5 +- ...ed-agent-session-owed-work-release.test.ts | 5 +- ...ent-session-pre-spawn-first-answer.test.ts | 7 +- ...nt-session-processless-reservation.test.ts | 23 ++--- ...gent-session-provider-child-record.test.ts | 5 +- ...red-agent-session-provider-restore.test.ts | 10 +-- ...red-agent-session-provider-started.test.ts | 5 +- ...session-queued-message-rig.test-fixture.ts | 7 +- ...tured-agent-session-recovery-exits.test.ts | 9 +- ...-agent-session-recovery-resolution.test.ts | 8 +- ...ctured-agent-session-refusal-retry.test.ts | 8 +- .../structured-agent-session-rest-test-rig.ts | 13 +-- ...ssion-restart-interruption-test-harness.ts | 8 +- ...session-restart-status-publication.test.ts | 7 +- ...tured-agent-session-rewind-at-rest.test.ts | 18 ++-- .../structured-agent-session-rewind.test.ts | 8 +- ...agent-session-send-open-stale-turn.test.ts | 7 +- ...red-agent-session-send-preparation.test.ts | 5 +- ...session-send-restarts-failed-start.test.ts | 5 +- ...agent-session-settled-attach-retry.test.ts | 9 +- ...agent-session-start-failure-writer.test.ts | 5 +- ...red-agent-session-surface-lifetime.test.ts | 9 +- ...sion-view-start-after-failed-start.test.ts | 5 +- ...t-session-wedged-profile-migration.test.ts | 30 ++----- .../structured-conversation-command.test.ts | 8 +- ...nt-session-conversation-name-store.test.ts | 9 +- ...session-death-evidence-persistence.test.ts | 23 ++--- ...agent-session-launch-env-admission.test.ts | 17 ++-- .../agent-session-lease-renewal.test.ts | 17 ++-- ...-session-older-build-lease.test-fixture.ts | 18 ++-- .../agent-session-record-options.test.ts | 6 +- ...ent-session-record-store-tab-table.test.ts | 12 ++- ...agent-session-record-store-test-harness.ts | 84 +++++++++++++++++++ .../agent-session-record-store.test.ts | 33 ++++---- ...ession-released-reservation-replay.test.ts | 5 +- ...sion-unsaved-creation-supersession.test.ts | 9 +- .../agent-launch-caller-selection.test.ts | 4 +- .../agent-launch-pane-reservation.test.ts | 5 +- .../agent-launch-prestart-failure.test.ts | 5 +- .../rpc/methods/agent-launch-replay.test.ts | 17 ++-- .../agent-launch-session-reservation.test.ts | 5 +- ...ured-agent-session-adoption-replay.test.ts | 7 +- .../structured-agent-session-hold.test.ts | 5 +- .../methods/structured-chat-tab-table.test.ts | 8 +- ...ructured-agent-session-integration.test.ts | 5 +- ...ent-session-rollback-compatibility.test.ts | 24 ++---- ...nt-session-spawn-token-descendants.test.ts | 6 +- .../structured-claude-pending-rewind.test.ts | 8 +- ...ss-version-agent-session-wire.unit.test.ts | 13 +-- ...-chat-owner-status-activation.unit.test.ts | 7 +- 78 files changed, 407 insertions(+), 396 deletions(-) create mode 100644 src/main/runtime/agent-session-record-store-test-harness.ts diff --git a/mobile/src/tasks/agent-launch-mobile-replay.test.ts b/mobile/src/tasks/agent-launch-mobile-replay.test.ts index 12a3bf495a01..aa654675f36b 100644 --- a/mobile/src/tasks/agent-launch-mobile-replay.test.ts +++ b/mobile/src/tasks/agent-launch-mobile-replay.test.ts @@ -12,7 +12,8 @@ import { } from '../../../src/shared/agent-session-host-authority' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' -import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' import type { OrcaRuntimeService } from '../../../src/main/runtime/orca-runtime' import { RpcDispatcher } from '../../../src/main/runtime/rpc/dispatcher' import { runtimeStub } from '../../../src/main/runtime/rpc/methods/agent-launch.test-fixture' @@ -30,7 +31,7 @@ beforeEach(async () => { createStructuredSession.mockReset() createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } }) directory = await mkdtemp(join(tmpdir(), 'orca-mobile-launch-replay-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads only deps.store; structured session creation is the injected boundary above. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts index 2d1828d2795c..3fc2ce2a4ca2 100644 --- a/mobile/src/tasks/mobile-agent-launch-architecture.test.ts +++ b/mobile/src/tasks/mobile-agent-launch-architecture.test.ts @@ -2,7 +2,8 @@ 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 { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' import { OrcaRuntimeRpcServer } from '../../../src/main/runtime/runtime-rpc' import { DeviceRegistry } from '../../../src/main/runtime/device-registry' import type { AuthenticatedMobileSocket } from '../../../src/main/runtime/rpc/mobile-socket-wiring' @@ -37,7 +38,7 @@ beforeEach(async () => { createStructuredSession.mockReset() createStructuredSession.mockResolvedValue({ ok: true, value: { sessionId: 'session-1' } }) directory = await mkdtemp(join(tmpdir(), 'orca-launch-architecture-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch reads deps.store; session creation is injected above. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) @@ -241,7 +242,7 @@ describe('mobile launch retry authority', () => { } const first = await dispatcher.dispatch(request) expect(first.ok).toBe(true) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) await expect( @@ -291,7 +292,7 @@ describe('mobile launch retry authority', () => { message: 'Branch "otter" already exists locally.' } }) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: launch admission reads only deps.store from the installed host. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) await expect(dispatcher.dispatch(request)).resolves.toMatchObject({ diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 30d814dcfa4e..97b0734cb8f4 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' @@ -53,10 +53,7 @@ beforeEach(async () => { sink = null clock = NOW closeSession = vi.fn(async () => true) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const adapter: StructuredAgentSessionAdapter = { acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 15f82b824343..cfc84ab36c93 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -16,7 +16,8 @@ import { } from '../../shared/structured-agent-session-reducer' import { projectStructuredAgentSessionMessages } from '../../shared/structured-agent-session-message-projection' import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection' -import { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../runtime/agent-session-record-store-test-harness' import { CodexJournalPrompts } from './codex-structured-journal-prompts' import { CODEX_USER_INPUT_METHOD } from './codex-structured-prompt-replies' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' @@ -90,7 +91,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async ({ commit }) => commit()), setOption: vi.fn(async () => undefined) } - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 0064f36ce584..4b8ff7c73ae1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -20,6 +20,7 @@ import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { @@ -109,7 +110,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) await startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 4e5d44795a31..25cc6dd568be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -5,7 +5,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionOptionsResult } from '../../../shared/agent-session-wire' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' @@ -116,10 +117,7 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { describe('structured session acquisition options', () => { it('samples provider history before acquiring a replacement child', async () => { root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const initialStore = await openTestAgentSessionRecordStore(join(root, 'store')) let childAcquired = false const historyWindow = (): ProviderHistoryWindow => ({ items: [], @@ -181,10 +179,7 @@ describe('structured session acquisition options', () => { fence: 1 }) await firstJournal!.close() - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 @@ -218,10 +213,7 @@ describe('structured session acquisition options', () => { it('persists create defaults before the first provider acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const sessionAdapter = adapter({ origin: 'created' }) const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } const recordPhase = vi.fn() @@ -252,10 +244,7 @@ describe('structured session acquisition options', () => { it('replays a create retried after the host re-resolved different options', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ @@ -287,7 +276,7 @@ describe('structured session acquisition options', () => { it('persists provider options before proving a resumed legacy record', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) const storeDir = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(storeDir) const created = await performAttach({ store, @@ -313,10 +302,7 @@ describe('structured session acquisition options', () => { now: NOW }) - const resumedStore = await AgentSessionRecordStore.open({ - directory: storeDir, - hostId: 'local' - }) + const resumedStore = await openTestAgentSessionRecordStore(storeDir) await resumedStore.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 @@ -345,7 +331,7 @@ describe('structured session acquisition options', () => { }) expect(resumed).toMatchObject({ ok: true }) - const reopened = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(storeDir) expect(reopened.getRecord(SESSION)?.options).toEqual({ approvalPolicy: 'on-request', personality: 'concise', @@ -357,10 +343,7 @@ describe('structured session acquisition options', () => { it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const sessionAdapter = adapter({ origin: 'created', options: { current: { model: 'gpt-standard' }, models: [] }, @@ -392,10 +375,7 @@ describe('structured session acquisition options', () => { it('releases an acquisition when provider options cannot be read', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const releaseAcquisition = vi.fn(async () => true) const failingAdapter: StructuredAgentSessionAdapter = { ...adapter({ origin: 'created' }), @@ -447,7 +427,7 @@ describe('structured session acquisition options', () => { const exitProven = cleanup === true root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) const storeDir = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(storeDir) const base = adapter({ origin: 'created', options: { current: { model: 'gpt-5.6-terra' }, models: [] } @@ -534,10 +514,7 @@ describe('structured session acquisition options', () => { exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' )) - const reopened = await AgentSessionRecordStore.open({ - directory: storeDir, - hostId: 'local' - }) + const reopened = await openTestAgentSessionRecordStore(storeDir) const failedRecord = reopened.getRecord(SESSION) expectSettledAttachLease(failedRecord) expect( @@ -600,7 +577,7 @@ describe('structured session acquisition options', () => { describe('the tab a create reserves', () => { async function openStore() { root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + return openTestAgentSessionRecordStore(join(root, 'store')) } function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index ac5f1d1b088c..df5f46500958 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -6,7 +6,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { attachFingerprintFields, @@ -119,7 +120,7 @@ async function attach( sessionAdapter: StructuredAgentSessionAdapter, onAttached: AttachFlowInput['onAttached'] = () => {} ) { - store ??= await AgentSessionRecordStore.open({ directory: join(root!, 'store'), hostId: 'local' }) + store ??= await openTestAgentSessionRecordStore(join(root!, 'store')) return performAttach({ store, adapter: sessionAdapter, @@ -230,7 +231,7 @@ describe('adopting a provider conversation on create', () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-host-failure-')) const transcriptPath = join(root, 'rollout.jsonl') await writeCodexRollout(transcriptPath, 'valid source') - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) const host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 19280db41e50..0585b330420e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -12,7 +12,8 @@ import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -125,7 +126,7 @@ beforeEach(async () => { acquisitionGeneration: `generation-${++generation}`, providerChildPhase: 'starting' as const })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index 0e34cca99a71..57ad992fd771 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -14,7 +14,8 @@ import { PROVIDER_SESSION_ID, type FakeConnection } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { structuredAgentSessionCommandTurn } from './structured-agent-session-command-turn' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -67,7 +68,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => adapterNow }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index 103df14eb733..bb4022924c39 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -14,7 +14,8 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -63,7 +64,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index 6787a4e7586e..c51ce418ec8f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -80,7 +81,7 @@ beforeEach(async () => { resetHostTestOperationIds() activeModel = DEFAULT_MODEL optionWritable = Promise.resolve() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index dfbe77c68ba6..8ccc268ee241 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -16,7 +16,8 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -79,7 +80,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index d201360cd542..59f8aad20cc2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -8,7 +8,7 @@ import { fakeClaude, identityFor } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { createTrackedJournalOpener, openTestJournalHostDatabase @@ -31,7 +31,7 @@ describe('Claude root-exit stop', () => { it('releases a captured live claim after the provider root exits', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-claude-root-exit-')) roots.push(root) - const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const claude = fakeClaude({ unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index 28b4dde82e07..f4d19388029c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -14,7 +14,7 @@ import { fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' import { codexTurnLifecycleFake } from '../../codex/codex-turn-lifecycle-fake' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -49,10 +49,7 @@ beforeEach(async () => { ) return {} } - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapterFor(codex), { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 5793f9677d8b..4eeff810dd4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -11,7 +11,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { CancelParams } from '../../../shared/rpc-contract/structured-agent-session-params' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -48,7 +49,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) cancelTurn = vi.fn(async () => ({ cancelled: true })) awaitStarted = vi.fn(async () => undefined) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index a4831b1a7b14..87d7ba1a79e4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -13,7 +13,8 @@ import { import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { openCodexAppServerConnection } from '../../codex/codex-app-server-connection' import { adapterFor, fakeCodex } from '../../codex/codex-structured-session-adapter-fixture' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -58,10 +59,7 @@ async function crash(dying: AgentSessionRecordStore): Promise { } function openStore(generation: HostGeneration): Promise { - return AgentSessionRecordStore.open({ - directory: join(generationRoot(generation), 'store'), - hostId: 'local' - }) + return openTestAgentSessionRecordStore(join(generationRoot(generation), 'store')) } /** A Codex adapter whose child spawns, reports its pid the way the real connection does, and then diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index ac1218fb2a78..54bd2789c00f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -4,7 +4,7 @@ // that runs for half a minute leaves one journal row at its start. The lease renewal the host // wrote every ten seconds is what saw the child working after that row. -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, vi } from 'vitest' @@ -18,8 +18,11 @@ import { completedStructuredAgentTurnSeconds, selectStructuredAgentTurnTimings } from '../../../shared/structured-agent-session-turn-timing' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import { closeTestJournalHostDatabases, openTestJournalHostDatabase @@ -97,20 +100,8 @@ function crashedClaudeRecord(): AgentSessionRecord { async function seedCrashedStore(): Promise { const directory = join(root, 'store') - await mkdir(directory, { recursive: true }) - await writeFile( - join(directory, AGENT_SESSION_STORE_FILE_NAME), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: crashedClaudeRecord() }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }), - 'utf-8' - ) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await seedTestAgentSessionRecordStore(directory, { records: [crashedClaudeRecord()] }) + store = await openTestAgentSessionRecordStore(directory) } /** A running turn whose only row after its start is a Bash call that never reported back, for a diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 3547f8bdd776..8d0f31367fb7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -5,7 +5,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionAcquisitionExitUnprovenError, AgentSessionAcquisitionRefusal, @@ -48,7 +49,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 435a9be82b16..9e88829aad31 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -6,7 +6,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionPreSpawnError, type StructuredAgentSessionAdapter @@ -44,7 +45,7 @@ beforeEach(async () => { }, acquisitionGeneration: `generation-${fence}` })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index df6ee094967e..29f6eabf703c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -7,7 +7,8 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import { encodeAgentSessionQuestionAnswers } from '../../../shared/agent-session-question-answer' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -130,7 +131,7 @@ beforeEach(async () => { } })) answerPrompt = vi.fn(async ({ commit }) => commit()) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index 19f2dd003a4d..0d1dfc5d6cb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' @@ -25,10 +25,7 @@ const CALLER = { callerKey: 'client-1' } describe('abandoning a structured agent-session host', () => { it('waits for the restart the delivery loop woke for an accepted send', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-abandon-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) // Holds the restart inside its provider acquisition, so the point teardown must not run past // is exact rather than a timing window. let gate: Promise | null = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index a0caaa45b936..f3528123f998 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -8,7 +8,8 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { createTrackedJournalOpener, openTestJournalHostDatabase @@ -147,7 +148,7 @@ beforeEach(async () => { cancelTurn = vi.fn(async () => ({ cancelled: true })) answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 590f69c79840..78454b675026 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -5,7 +5,8 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' import { join } from 'node:path' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -560,7 +561,7 @@ describe('restart', () => { adapterOverrides: Partial = {}, stopOwnerProcess?: StructuredAgentSessionHostDeps['stopOwnerProcess'] ) { - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), ...adapterOverrides }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts index fca1d49c06cf..8d96bf346320 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -4,7 +4,7 @@ import { cp, rm } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { JournalImportMismatchError, @@ -92,10 +92,7 @@ async function relaunchWithMismatchedCopy(): Promise recursive: true, filter: (source) => !source.includes('.lock') }) - const store = await AgentSessionRecordStore.open({ - directory: join(relaunched, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(relaunched, 'store')) const host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 4dfbac37c42c..5383914012a9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -9,7 +9,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { AgentSessionDispatchOutcome, @@ -92,7 +93,7 @@ beforeEach(async () => { resetHostTestOperationIds() dispatch = vi.fn(async () => accepted()) closeSession = vi.fn(async () => true) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts index 9545e42b051c..9399e4d0242b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.test.ts @@ -6,7 +6,8 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionLeaseRenewer } from './structured-agent-session-lease-renewer' const NOW = 1_800_000_000_000 @@ -15,7 +16,7 @@ const roots: string[] = [] async function liveStore(): Promise { const root = await mkdtemp(join(tmpdir(), 'orca-lease-renewer-')) roots.push(root) - const store = await AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(root) const reserved = await store.reserveOwner({ sessionId: 'session-renewal', location: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index cea81c6c4fe4..e936ef337a2a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -10,7 +10,8 @@ import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { PersistedAgentSessionLease } from '../../../shared/agent-session-legacy-handoff-lease' import { writeOlderBuildLease } from '../../runtime/agent-session-older-build-lease.test-fixture' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -73,7 +74,7 @@ async function persistFromOlderBuild(lease: OlderBuildLease): Promise { await host.flushAllStreamedEvents() // Over the attached owner: the older build's stage or terminal owner kept it from releasing. await writeOlderBuildLease(join(root, 'store'), SESSION, { ...attached, ...lease }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) acquire.mockClear() openHost() } @@ -130,7 +131,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) openHost() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index ea543785dde5..5e53dadf8beb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -22,7 +22,8 @@ import { reduceStructuredAgentSession, type StructuredAgentSessionState } from '../../../shared/structured-agent-session-reducer' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -58,7 +59,7 @@ beforeEach(async () => { listed = undefined // Written, and the provider has neither opened a turn for it nor answered it. dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 7445c1e3894c..ea7abc170c35 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -7,7 +7,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionStatusEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { AgentSessionOptionRejectedError } from './structured-agent-session-option-error' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -120,7 +121,7 @@ beforeEach(async () => { closeSessionExit = true dispatchedModels.length = 0 const accountHome = join(root, 'codex-home') - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) router = adapter() host = new StructuredAgentSessionHost({ store, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 340b35cf21ce..ab77dea332c9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -18,7 +18,8 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -85,7 +86,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index fd41ba7890f2..d3fb132954f7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { mapRuntimeError } from '../../runtime/rpc/errors' import { AgentSessionPreSpawnError, @@ -66,10 +66,7 @@ function createParams(): AgentSessionAttachParams { /** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const unused = async (): Promise => { throw new Error('not reached before a spawn') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 204ffba30da6..b6df344d01ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionPreSpawnError, type StructuredAgentSessionAdapter @@ -68,10 +68,7 @@ function attachParams( describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const reserveOwner = vi.spyOn(store, 'reserveOwner') const acquire = vi.fn() const adapter = { @@ -109,10 +106,7 @@ describe('processless structured session reservation', () => { it('refuses a replay when adapter support drifts after durable reservation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -159,10 +153,7 @@ describe('processless structured session reservation', () => { it('releases a new reservation when support drifts before acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -215,7 +206,7 @@ describe('processless structured session reservation', () => { it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) const storeDir = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(storeDir) const adapter = { acquire: vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) @@ -253,7 +244,7 @@ describe('processless structured session reservation', () => { }) expect(store.listOperationRows()[0]?.outcome).toMatchObject({ status: 'failed' }) - const reopened = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(storeDir) await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'indeterminate', reason: 'no owner to probe' }), now: NOW + 1 @@ -268,7 +259,7 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) const storeDir = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(storeDir) const adapter = { acquire: vi .fn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index 74ae75e45d93..c302a5771561 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -22,7 +22,8 @@ import { import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' import { StructuredAgentSessionHost } from './structured-agent-session-host' @@ -115,7 +116,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) await host.close(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index e5025551360b..3466c5e4dfd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -2,7 +2,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' @@ -78,7 +79,7 @@ describe('structured session provider restore', () => { root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) resetHostTestOperationIds() const storeDirectory = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(storeDirectory) const host = createHost(store) const attached = await host.attach( { callerKey: 'client-1' }, @@ -91,10 +92,7 @@ describe('structured session provider restore', () => { ) expect(attached).toMatchObject({ ok: true }) - const reopenedStore = await AgentSessionRecordStore.open({ - directory: storeDirectory, - hostId: 'local' - }) + const reopenedStore = await openTestAgentSessionRecordStore(storeDirectory) const restarted = createHost(reopenedStore, async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 892e45a4ad0d..9b3cd4bc378a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -12,7 +12,8 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -72,7 +73,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index 739e64e94ece..bb86b5e39c1d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -10,7 +10,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' import type { AgentSessionQueuePause } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' @@ -52,10 +52,7 @@ export async function createQueuedMessageTestRig() { providerIdentity: null })) let events: StructuredAgentSessionEventSink | undefined - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 4728d4256de2..66723f45654a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -6,7 +6,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { spawnProcess } from '../../../shared/child-process/run-process' import { CODEX_SPAWN_TOKEN_ENV } from '../../codex/codex-structured-owner-identity' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { readProcessStartTimeMs } from '../../runtime/agent-session-process-identity-probe' import { createStructuredAgentSessionOwnerProbe } from '../../runtime/structured-agent-session-owner-probe' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -92,7 +93,7 @@ function openHost(overrides: Partial = {}): void async function reopenStore(): Promise { await abandonStructuredAgentSessionHost(host) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) } beforeEach(async () => { @@ -113,7 +114,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) openHost() }) @@ -296,7 +297,7 @@ describe('recovery exits', () => { const outgoingHost = host const outgoingStore = store supersededHosts.add(outgoingHost) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) const realProbe = createStructuredAgentSessionOwnerProbe('local') let overlapDriven = false openHost({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts index 063e8c64af71..8e3be9d1f3d7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution.test.ts @@ -8,7 +8,8 @@ import { readPersistedLease, writeOlderBuildLease } from '../../runtime/agent-session-older-build-lease.test-fixture' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { supervisedPosixLaunch } from '../../codex/codex-app-server-posix-supervisor' import { resolveStructuredSessionRecovery, @@ -32,10 +33,7 @@ async function newStoreDirectory(): Promise { } async function openStore(directory?: string): Promise { - return AgentSessionRecordStore.open({ - directory: directory ?? (await newStoreDirectory()), - hostId: 'local' - }) + return openTestAgentSessionRecordStore(directory ?? (await newStoreDirectory())) } async function reserve(store: AgentSessionRecordStore) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 4c2db496c3cf..9d84252472b5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -16,7 +16,8 @@ import { agentSessionRefusalOperationState, type AgentSessionRefusalOperationState } from '../../../shared/agent-session-refusal-retry' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { abandonStructuredAgentSessionHost } from './structured-agent-session-host-test-abandon' @@ -58,10 +59,7 @@ function operationId(timestamp = NOW): string { async function createHarness(options: { attached?: boolean } = {}) { const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const setOption = vi.fn(async () => undefined) const adapter: StructuredAgentSessionAdapter = { acquire: async ({ fence }) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 782c1f161156..a95b1fed8683 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -13,7 +13,8 @@ import type { AgentSessionStatusEvent, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -126,10 +127,7 @@ export async function createRestTestRig( const clock = { now: HOST_TEST_NOW } const statusEvents: AgentSessionStatusEvent[] = [] const sink = { publish: vi.fn(), forget: vi.fn() } - let store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + let store = await openTestAgentSessionRecordStore(join(root, 'store')) const adapter: RestTestAdapter = { acquire: vi.fn(async ({ fence, spawnToken }) => ({ acquisitionGeneration: `generation-${++generations}`, @@ -180,10 +178,7 @@ export async function createRestTestRig( sink, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) - store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) rig.store = store rig.host = hostFor(overrides) rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 30eae55f36a9..7f425f61a7ea 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -9,7 +9,8 @@ import { AgentSessionRecoveryCapsule, AGENT_SESSION_RECOVERY_CAPSULE_FILE } from '../../runtime/agent-session-recovery-capsule' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -115,10 +116,7 @@ export async function interruptedRestart( } await previous.host.flushStreamedEvents(SESSION) await previous.host.flushAllStreamedEvents() - const store = await AgentSessionRecordStore.open({ - directory: join(previous.root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(previous.root, 'store')) const closeSession = vi.fn(async () => true) // The relaunch comes after the quit that recorded the offer. const clock = { now: NOW + 1 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 87e87a408c32..601eed15c3e3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -13,7 +13,8 @@ import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -92,7 +93,7 @@ async function restartWithPersistedTurn(): Promise { root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) resetHostTestOperationIds() const directory = join(root, 'store') - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) const host = createHost(store) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') @@ -103,7 +104,7 @@ async function restartWithPersistedTurn(): Promise { // Delivered, not just accepted: a message still queued at the restart was never a request. await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) await host.flushAllStreamedEvents() - return createHost(await AgentSessionRecordStore.open({ directory, hostId: 'local' })) + return createHost(await openTestAgentSessionRecordStore(directory)) } afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index d00f6a244657..c1fb0009346f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -8,7 +8,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -106,10 +107,7 @@ beforeEach(async () => { } })) directory = await mkdtemp(join(tmpdir(), 'orca-rewind-rest-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(directory, 'store')) host = openHost() }) @@ -174,10 +172,7 @@ async function interruptedRewindAtRest(): Promise { ) expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(directory, 'store')) host = openHost() } @@ -212,10 +207,7 @@ describe('a rewind asked of a chat at rest (P2-23)', () => { await host.flushStreamedEvents(SESSION) const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(directory, 'store')) host = openHost() const before = acquires rewindSupport.mockReturnValue({ supported: false, reason: 'history-not-paginated' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 7b4baa03df87..8232d287f63f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -10,7 +10,8 @@ import { } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { nativeChatTurnMembership } from '../../../shared/native-chat-turn-membership' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionAdapter, @@ -53,10 +54,7 @@ beforeEach(async () => { }) acquires = [] directory = await mkdtemp(join(tmpdir(), 'orca-rewind-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(directory, 'store')) adapter = { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index 3718a60a7a98..e5a8388f7794 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -9,7 +9,7 @@ import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { adapter, @@ -66,10 +66,7 @@ async function relaunchAfterCrashMidTurn( recursive: true, filter: (source) => !source.includes('.lock') }) - const store = await AgentSessionRecordStore.open({ - directory: join(relaunched, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(relaunched, 'store')) const acquire = vi.fn(async () => { throw new Error('claude: command not found') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index e435110f1399..8fc9cbbf75f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -10,7 +10,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { CodexAppServerRequestError } from '../../codex/codex-app-server-request-error' @@ -68,7 +69,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index d8390feae1c4..c6f387a68b1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -14,7 +14,8 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -143,7 +144,7 @@ beforeEach(async () => { providerChildPhase: 'starting' as const })) dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 27b408933b7c..462111f14ade 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -9,7 +9,8 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vite import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type * as DurableFileWrite from '../../durable-file-write' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -109,7 +110,7 @@ beforeEach(async () => { })) releaseAcquisition = vi.fn(async () => true) dispatch = vi.fn(async () => accepted()) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: adapter(), @@ -267,7 +268,7 @@ describe('settled attach retry', () => { }) await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: adapter(), @@ -324,7 +325,7 @@ describe('settled attach retry', () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 69bed14b7b12..517d136b581f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -14,7 +14,8 @@ import { import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { HOST_TEST_NOW as NOW, @@ -117,7 +118,7 @@ beforeEach(async () => { awaitStarted = vi.fn( () => new Promise((resolve) => (settleStart = resolve)) ) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 586f8c826300..c25c92d6affb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -18,7 +18,8 @@ import type { AgentSessionMutationEnvelope, AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { AgentSessionAcquisitionRootExitObservedError, type StructuredAgentSessionAdapter @@ -91,7 +92,7 @@ function openHost( /** A fresh app generation over the same durable store, with its owner proven gone. */ async function reboot(): Promise { await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) openHost(async () => ({ outcome: 'pid-absent' })) acquire.mockClear() closeSession.mockClear() @@ -231,7 +232,7 @@ beforeEach(async () => { surface: 'rejection' }) })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) openHost() }) @@ -400,7 +401,7 @@ describe('startup', () => { const beforeRestart = store.getRecord(SESSION) await abandonStructuredAgentSessionHost(host) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) openHost(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index a49d411c9cf8..ac773034ced6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -12,7 +12,8 @@ import { fakeClaude, PROVIDER_SESSION_ID } from '../../claude/claude-structured-session-test-support' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { structuredClaudeLifecycleEvent } from '../../runtime/structured-claude-runtime-adapter' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -81,7 +82,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index da3072272576..72d88caa226d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -11,7 +11,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa // leave the lease in a state an attach can claim, and these tests prove that by adjudicating it // rather than by reading fields off it. -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, vi, type Mock } from 'vitest' @@ -31,8 +31,11 @@ import type { PersistedAgentSessionRecord, PersistedAgentSessionRuntimeKind } from '../../../shared/agent-session-legacy-handoff-lease' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from '../../runtime/agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -112,20 +115,8 @@ function wedgedRecord(overrides: WedgeOverrides): PersistedAgentSessionRecord { async function seedStore(record: PersistedAgentSessionRecord): Promise { const directory = join(root, 'store') - await mkdir(directory, { recursive: true }) - await writeFile( - join(directory, AGENT_SESSION_STORE_FILE_NAME), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [record.sessionId]: record }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }), - 'utf-8' - ) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + await seedTestAgentSessionRecordStore(directory, { records: [record] }) + store = await openTestAgentSessionRecordStore(directory) } /** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ @@ -283,10 +274,7 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).not.toHaveBeenCalled() await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) openHost() await host.restoreReadableSessions() diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 1f6d6016cf83..220b12f1d4a3 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -6,7 +6,8 @@ import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-se import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionConversationCommand } from '../../../shared/agent-session-conversation-command' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import { StructuredAgentSessionHost } from './structured-agent-session-host' import { AgentSessionAcquisitionRefusal, @@ -61,10 +62,7 @@ const generationRoot = () => join(directory, `generation-${generation}`) let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } async function openHost(): Promise { - store = await AgentSessionRecordStore.open({ - directory: join(generationRoot(), 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(generationRoot(), 'store')) host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/main/runtime/agent-session-conversation-name-store.test.ts b/src/main/runtime/agent-session-conversation-name-store.test.ts index 1e8067f6dd20..07c1345f8da4 100644 --- a/src/main/runtime/agent-session-conversation-name-store.test.ts +++ b/src/main/runtime/agent-session-conversation-name-store.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -49,7 +50,7 @@ afterEach(async () => { }) async function reservedStore(): Promise { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) await store.reserveOwner(reserveRequest()) return store } @@ -60,7 +61,7 @@ describe('AgentSessionRecordStore.setConversationName', () => { await store.setConversationName(SESSION, 'Fix the lease probe') - const reloaded = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reloaded = await openTestAgentSessionRecordStore(directory) expect(reloaded.getRecord(SESSION)?.conversationName).toBe('Fix the lease probe') }) @@ -73,7 +74,7 @@ describe('AgentSessionRecordStore.setConversationName', () => { expect(name).toHaveLength(200) expect(name.startsWith('Fix the ')).toBe(true) // A reload validates every record; an over-long name would be dropped as unreadable. - const reloaded = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reloaded = await openTestAgentSessionRecordStore(directory) expect(reloaded.getRecord(SESSION)?.conversationName).toBe(name) }) diff --git a/src/main/runtime/agent-session-death-evidence-persistence.test.ts b/src/main/runtime/agent-session-death-evidence-persistence.test.ts index 6e0bff8098cd..9904ca7994f4 100644 --- a/src/main/runtime/agent-session-death-evidence-persistence.test.ts +++ b/src/main/runtime/agent-session-death-evidence-persistence.test.ts @@ -1,4 +1,4 @@ -import { 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, vi } from 'vitest' @@ -8,8 +8,11 @@ import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' import type { AgentSessionFailedAcquisitionSettlement } from './agent-session-acquisition-failure-settlement' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' const SESSION = 'session-alpha-1' /** The fixture lease's last renewal. */ @@ -27,21 +30,11 @@ async function seed(deathEvidence: AgentSessionDeathEvidence | null): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) + return openTestAgentSessionRecordStore(directory) } beforeEach(async () => { diff --git a/src/main/runtime/agent-session-launch-env-admission.test.ts b/src/main/runtime/agent-session-launch-env-admission.test.ts index 51b2fe64dafb..6cb9a576078f 100644 --- a/src/main/runtime/agent-session-launch-env-admission.test.ts +++ b/src/main/runtime/agent-session-launch-env-admission.test.ts @@ -1,8 +1,11 @@ -import { mkdtemp, readFile, rm } 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 { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStoreText +} from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -45,7 +48,7 @@ afterEach(async () => { describe('agent session launch environment admission', () => { it('does not persist ambient launch variables', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) await store.reserveOwner(request()) await store.reserveOwner( request({ @@ -63,15 +66,15 @@ describe('agent session launch environment admission', () => { }) ) - const raw = await readFile(join(directory, 'agent-sessions.json'), 'utf-8') + const raw = await readPersistedTestAgentSessionStoreText(directory) expect(raw).not.toContain('OPENAI_API_KEY') expect(raw).not.toContain('"PATH"') - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) expect(reopened.getRecord(SESSION)).not.toHaveProperty('launchEnv') }) it('rejects an environment that could not be validated before writing', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) const launchEnv = Object.fromEntries( Array.from({ length: 257 }, (_, index) => [`KEY_${index}`, 'value']) ) @@ -83,7 +86,7 @@ describe('agent session launch environment admission', () => { }) it('rejects an overlong environment key before writing it', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) await expect( store.reserveOwner(request({ launchEnv: { ['K'.repeat(513)]: 'value' } })) diff --git a/src/main/runtime/agent-session-lease-renewal.test.ts b/src/main/runtime/agent-session-lease-renewal.test.ts index b2788f816029..ec636a96eed5 100644 --- a/src/main/runtime/agent-session-lease-renewal.test.ts +++ b/src/main/runtime/agent-session-lease-renewal.test.ts @@ -1,10 +1,13 @@ -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStoreText +} from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const MATCHED = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } as const @@ -63,7 +66,7 @@ async function establishOwner( async function liveStore(): Promise<{ directory: string; store: AgentSessionRecordStore }> { const directory = await mkdtemp(join(tmpdir(), 'orca-lease-renewal-batch-')) directories.push(directory) - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) await establishOwner(store, directory, 'a') await establishOwner(store, directory, 'b') return { directory, store } @@ -93,7 +96,7 @@ describe('agent-session lease renewal batch', () => { probe: { outcome: 'pid-absent' }, now: NOW + 5_000 }) - const beforeDisk = await readFile(agentSessionStorePath(directory), 'utf-8') + const beforeDisk = await readPersistedTestAgentSessionStoreText(directory) const beforeFirst = store.getRecord('session-a') await expect( @@ -104,8 +107,8 @@ describe('agent-session lease renewal batch', () => { ).rejects.toThrow('agent_session_checkpoint_stale') expect(store.getRecord('session-a')).toEqual(beforeFirst) - expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(beforeDisk) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + expect(await readPersistedTestAgentSessionStoreText(directory)).toBe(beforeDisk) + const reopened = await openTestAgentSessionRecordStore(directory) expect( reopened .listRecords() diff --git a/src/main/runtime/agent-session-older-build-lease.test-fixture.ts b/src/main/runtime/agent-session-older-build-lease.test-fixture.ts index 906921645d4d..869f8ddc14c2 100644 --- a/src/main/runtime/agent-session-older-build-lease.test-fixture.ts +++ b/src/main/runtime/agent-session-older-build-lease.test-fixture.ts @@ -1,8 +1,8 @@ -import { readFile, writeFile } from 'node:fs/promises' import type { PersistedAgentSessionLease } from '../../shared/agent-session-legacy-handoff-lease' -import { agentSessionStorePath } from './agent-session-record-store-file' - -type StoreFile = { records: Record }> } +import { + editPersistedTestAgentSessionStore, + readPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' /** Rewrites one lease on disk as an older build left it; this build's types cannot express it. */ export async function writeOlderBuildLease( @@ -10,16 +10,14 @@ export async function writeOlderBuildLease( sessionId: string, fields: Partial ): Promise { - const path = agentSessionStorePath(directory) - const file: StoreFile = JSON.parse(await readFile(path, 'utf-8')) - file.records[sessionId].lease = { ...file.records[sessionId].lease, ...fields } - await writeFile(path, JSON.stringify(file)) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + persisted.records[sessionId].lease = { ...persisted.records[sessionId].lease, ...fields } + }) } export async function readPersistedLease( directory: string, sessionId: string ): Promise> { - const file: StoreFile = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) - return file.records[sessionId].lease + return (await readPersistedTestAgentSessionStore(directory)).records[sessionId].lease } diff --git a/src/main/runtime/agent-session-record-options.test.ts b/src/main/runtime/agent-session-record-options.test.ts index 5ba7c41cffcc..58385e292726 100644 --- a/src/main/runtime/agent-session-record-options.test.ts +++ b/src/main/runtime/agent-session-record-options.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' import { readNativeSessionOptions } from '../native-chat/agent-session-wire/structured-agent-session-option-restoration' -import { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SESSION = 'session-options' @@ -46,7 +46,7 @@ it('drops provider-rejected persisted options before the next owner proof', asyn }) it('persists resumed provider options atomically with owner proof', async () => { - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) const reserved = await store.reserveOwner({ sessionId: SESSION, location: { @@ -105,6 +105,6 @@ it('persists resumed provider options atomically with owner proof', async () => ...(options ? { options } : {}) }) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) expect(reopened.getRecord(SESSION)?.options).toEqual({ model: 'gpt-tui', effort: 'low' }) }) diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index 56bcb6debb6a..696eb5fd1cb0 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -10,7 +10,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' import { agentSessionStorePath } from './agent-session-record-store-file' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' @@ -61,7 +65,7 @@ function reserveRequest( } async function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) + return openTestAgentSessionRecordStore(directory) } describe('chat tab table', () => { @@ -74,11 +78,11 @@ describe('chat tab table', () => { await store.reserveOwner(reserveRequest({ surfaceTabId: 'tab-alpha' })) // A create that dies before its tab is shown leaves nothing to restore or release. expect(store.getSessionTabId('session-alpha')).toBeNull() - expect((await readFileJson()).sessionTabs).toBeUndefined() + expect((await readPersistedTestAgentSessionStore(directory)).sessionTabs).toBeUndefined() await store.setSessionTabVisibility('session-alpha', true, 'tab-alpha') expect(store.getSessionTabId('session-alpha')).toBe('tab-alpha') - const persisted = await readFileJson() + const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.sessionTabs).toEqual([{ tabId: 'tab-alpha', sessionId: 'session-alpha' }]) // Not copied onto the record: the table is the one place the id lives. expect(persisted.records['session-alpha']).not.toHaveProperty('surfaceTabId') diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts new file mode 100644 index 000000000000..d590ecd296d1 --- /dev/null +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -0,0 +1,84 @@ +/** + * The one way tests open, seed and read back the durable agent-session record store, so a change + * to where or how the store persists is made here rather than in every test that uses it. + */ + +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' +import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + agentSessionStorePath, + type RetiredAgentSessionClaimKey +} from './agent-session-record-store-file' + +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 + records: Record + operations: Record + retiredClaimKeys: RetiredAgentSessionClaimKey[] + unusableRecords: Record + /** Absent until the store first records a chat tab. */ + sessionTabs?: { tabId: string; sessionId: string }[] +} + +/** Opens, or reopens, the store kept in `directory`: what a fresh app process does at launch. */ +export function openTestAgentSessionRecordStore( + directory: string, + options: { hostId?: string } = {} +): Promise { + return AgentSessionRecordStore.open({ directory, hostId: options.hostId ?? TEST_HOST_ID }) +} + +/** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ +export async function seedTestAgentSessionRecordStore( + directory: string, + seed: { records: readonly PersistedAgentSessionRecord[] } +): Promise { + const persisted: PersistedTestAgentSessionStore = { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: TEST_HOST_ID, + records: Object.fromEntries(seed.records.map((record) => [record.sessionId, record])), + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + } + await mkdir(directory, { recursive: true }) + await writeFile(agentSessionStorePath(directory), JSON.stringify(persisted), 'utf-8') +} + +/** Leaves an empty store a newer build wrote: this build reads it but never writes it. */ +export async function seedTestAgentSessionStoreFromNewerBuild(directory: string): Promise { + await mkdir(directory, { recursive: true }) + await writeFile( + agentSessionStorePath(directory), + JSON.stringify({ schemaVersion: 99, hostId: TEST_HOST_ID, records: {}, operations: {} }) + ) +} + +/** Everything the store has committed, as text: to assert a value never reached disk, or that an + * action wrote nothing by comparing two reads. */ +export function readPersistedTestAgentSessionStoreText(directory: string): Promise { + return readFile(agentSessionStorePath(directory), 'utf-8') +} + +export async function readPersistedTestAgentSessionStore( + directory: string +): Promise { + return JSON.parse(await readPersistedTestAgentSessionStoreText(directory)) +} + +/** Changes the committed state behind the store's back, as an older build or a damaged disk would. */ +export async function editPersistedTestAgentSessionStore( + directory: string, + edit: (persisted: PersistedTestAgentSessionStore) => void +): Promise { + const persisted = await readPersistedTestAgentSessionStore(directory) + edit(persisted) + await writeFile(agentSessionStorePath(directory), JSON.stringify(persisted)) +} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 5ee25d36813d..7ea6174da70d 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -1,4 +1,4 @@ -import { 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, beforeEach, describe, expect, it } from 'vitest' @@ -10,7 +10,14 @@ import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + editPersistedTestAgentSessionStore, + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore, + seedTestAgentSessionStoreFromNewerBuild +} from './agent-session-record-store-test-harness' import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' import { agentSessionStorePath, @@ -97,7 +104,7 @@ function handleLink( let directory: string async function open(hostId = 'local'): Promise { - return AgentSessionRecordStore.open({ directory, hostId }) + return openTestAgentSessionRecordStore(directory, { hostId }) } /** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ @@ -550,7 +557,7 @@ describe('restart reconciliation', () => { releaseProbe({ outcome: 'pid-absent' }) expect(await reconciliation).toEqual(new Map()) - const persisted = JSON.parse(await readFile(agentSessionStorePath(directory), 'utf-8')) + const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.records['session-alpha'].lease).toMatchObject({ runtimeFence: 3, claimStatus: 'live', @@ -803,20 +810,20 @@ describe('claim keys, checkpoints, and unreadable rows', () => { it.each([ [ 'invalid checkpoint', - (record: AgentSessionRecord) => + (record: PersistedAgentSessionRecord) => Object.assign(record.lease, { journalCheckpoint: { epoch: 'bad', sequence: 1 } }) ], [ 'missing live proof', - (record: AgentSessionRecord) => Object.assign(record.lease, { provenHandleLinkId: null }) + (record: PersistedAgentSessionRecord) => + Object.assign(record.lease, { provenHandleLinkId: null }) ] ])('quarantines a record with %s', async (_name, corrupt) => { const first = await open() await establishOwner(first) - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - corrupt(raw.records['session-alpha']) - await writeFile(filePath, JSON.stringify(raw)) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + corrupt(persisted.records['session-alpha']) + }) expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) }) @@ -863,11 +870,7 @@ describe('claim keys, checkpoints, and unreadable rows', () => { }) it('refuses to write a store written by a newer schema', async () => { - const filePath = agentSessionStorePath(directory) - await writeFile( - filePath, - JSON.stringify({ schemaVersion: 99, hostId: 'local', records: {}, operations: {} }) - ) + await seedTestAgentSessionStoreFromNewerBuild(directory) const store = await open() expect(store.readOnly).toBe(true) await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( diff --git a/src/main/runtime/agent-session-released-reservation-replay.test.ts b/src/main/runtime/agent-session-released-reservation-replay.test.ts index e3f9bcff74bc..e5f54265dcb7 100644 --- a/src/main/runtime/agent-session-released-reservation-replay.test.ts +++ b/src/main/runtime/agent-session-released-reservation-replay.test.ts @@ -12,7 +12,8 @@ import { } from '../../shared/agent-session-host-authority' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { AgentSessionLease } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -33,7 +34,7 @@ afterEach(async () => { }) function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) + return openTestAgentSessionRecordStore(directory) } function createRequest( diff --git a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts index 50d27baa57ac..31e473fb5e66 100644 --- a/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts +++ b/src/main/runtime/agent-session-unsaved-creation-supersession.test.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle' import { codexProviderHandleLink } from '../codex/codex-structured-owner-identity' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -72,7 +73,7 @@ async function restart(store: AgentSessionRecordStore) { probe: { outcome: 'exit-observed' }, now: NOW }) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'reservation-unused' }), now: NOW @@ -90,7 +91,7 @@ afterEach(async () => { describe('a Codex thread started in place of one Codex never saved', () => { it('becomes the session identity and survives the next restart', async () => { - const first = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const first = await openTestAgentSessionRecordStore(directory) await prove(first, reserveRequest(), (fence) => codexProviderHandleLink({ threadId: 'thread-unsaved', @@ -127,7 +128,7 @@ describe('a Codex thread started in place of one Codex never saved', () => { }) it('is refused once a resume has proved the conversation Codex saved', async () => { - const first = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const first = await openTestAgentSessionRecordStore(directory) await prove(first, reserveRequest(), (fence) => codexProviderHandleLink({ threadId: 'thread-saved', resumed: false, fence, observedAt: NOW }) ) diff --git a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts index 8ef0f2c904dd..2e99f5801254 100644 --- a/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-caller-selection.test.ts @@ -8,7 +8,7 @@ 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 { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { RpcContext } from '../core' @@ -153,7 +153,7 @@ describe('a replayed launch', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-caller-')) - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts index 22d025106f19..d1d419a4c562 100644 --- a/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-pane-reservation.test.ts @@ -11,7 +11,8 @@ 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 { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -220,7 +221,7 @@ describe('a live-pane refusal under a named operation', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-pane-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts index 6b7adb8dbfc7..cfc14ec55041 100644 --- a/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-prestart-failure.test.ts @@ -8,7 +8,8 @@ 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 { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -55,7 +56,7 @@ describe('a launch whose terminal fails', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-prestart-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 336cd4c212b5..2cb953063391 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -24,7 +24,11 @@ import { settleAgentSessionOperation, type AgentSessionOperationRow } from '../../../../shared/agent-session-operation-ledger' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore +} from '../../agent-session-record-store-test-harness' import { agentSessionStorePath } from '../../agent-session-record-store-file' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' @@ -120,7 +124,7 @@ beforeEach(async () => { attachCallerKeys.length = 0 createStructuredSession.mockClear() directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-replay-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // The launch reaches the ledger through the installed host; nothing else on the host is used, // because the structured create below it is mocked out. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. @@ -241,7 +245,7 @@ describe('a replay answers from the record', () => { const params = createLaunch({ operationId: OPERATION_ID }) const first = await launch(params, runtime) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see the setup above. setStructuredAgentSessionHost({ deps: { store: reopened } @@ -411,9 +415,8 @@ describe('the recorded row stays readable by a build that predates it', () => { runtimeStub({ createSupport: { supported: false, reason: 'agent' } }) ) - const file: { operations: Record }> } = JSON.parse( - await readFile(agentSessionStorePath(directory), 'utf-8') - ) + const file: { operations: Record }> } = + await readPersistedTestAgentSessionStore(directory) const outcome = Object.values(file.operations)[0].outcome // The ratchet, and the reason this is not a new status arm or an optional `sessionId`: a build @@ -465,7 +468,7 @@ describe('an unreadable launch payload costs one replay, never the store', () => await launch(params, runtime) await rewriteRecordedLaunch({ outcome: { kind: 'structured' }, worktreeId: 'wt-1' }) - const reopened = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + const reopened = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: see the setup above. setStructuredAgentSessionHost({ deps: { store: reopened } diff --git a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts index f59e7c676bd2..528490d1d0a6 100644 --- a/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-session-reservation.test.ts @@ -10,7 +10,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentLaunchFingerprint } from '../../../../shared/agent-launch-operation' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { OrcaRuntimeService } from '../../orca-runtime' @@ -208,7 +209,7 @@ describe('a taken session id under a named operation', () => { beforeEach(async () => { directory = await mkdtemp(join(tmpdir(), 'orca-agent-launch-session-')) - store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) + store = await openTestAgentSessionRecordStore(directory) // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `deps.store` is the only member `agent.launch` reads, and a member it omits throws on call. setStructuredAgentSessionHost({ deps: { store } } as unknown as StructuredAgentSessionHost) }) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 4748c7dd5a67..272cb8bbb92b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' @@ -176,10 +176,7 @@ describe('committed adopting create RPC replay', () => { .mockRejectedValueOnce(new Error('simulated lost tab publication')) .mockResolvedValue(undefined) - const store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + const store = await openTestAgentSessionRecordStore(join(root, 'store')) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ store, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index aa2b2da15486..008038cb0190 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -19,7 +19,8 @@ import { } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' @@ -75,7 +76,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 9bd7b1fade0e..4d6a313f8b5b 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -24,7 +24,8 @@ import { resetHostTestOperationIds } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' -import { AgentSessionRecordStore } from '../../agent-session-record-store' +import type { AgentSessionRecordStore } from '../../agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' import { agentSessionStorePath } from '../../agent-session-record-store-file' import { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' @@ -87,10 +88,7 @@ function providerAdapter(): StructuredAgentSessionAdapter { } async function openHost(): Promise { - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(directory, 'store')) host = new StructuredAgentSessionHost({ store, adapter: providerAdapter(), diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index cd8939b93888..2e2d87d65471 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -6,7 +6,7 @@ // that ship. The fake app-server answers the same JSON-RPC calls the real one // does and pushes the same notifications and blocking requests back. -import { 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, beforeEach, describe, expect, it, vi } from 'vitest' @@ -34,6 +34,7 @@ import { importLegacyTranscriptIntoJournal } from '../native-chat/agent-session- import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { OrcaRuntimeService } from './orca-runtime' +import { readPersistedTestAgentSessionStoreText } from './agent-session-record-store-test-harness' import type { RpcRequest, RpcResponse } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' import type { NativeChatShellEnvironmentPolicy } from '../../shared/native-chat-shell-environment' @@ -455,7 +456,7 @@ describe('a structured codex session over agentSession.*', () => { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/home/dev/.codex' }) - const store = await readFile(join(root, 'agent-sessions', 'agent-sessions.json'), 'utf-8') + const store = await readPersistedTestAgentSessionStoreText(join(root, 'agent-sessions')) expect(store).not.toContain('EXAMPLE_GATEWAY_TOKEN') expect(store).not.toContain('"launchEnv"') const stream = await subscribe('sub-first-send') diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index 293ec194aae7..48a9d33142c2 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -5,7 +5,10 @@ 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 { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionRecordStore +} from './agent-session-record-store-test-harness' import { AGENT_SESSION_STORE_FILE_NAME } from './agent-session-record-store-file' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' @@ -78,28 +81,17 @@ describe('structured session rollback compatibility', () => { it('keeps the visible session reference through target → base → target', async () => { root = await mkdtemp(join(tmpdir(), 'orca-structured-rollback-')) const storeDir = join(root, 'agent-store') - await mkdir(storeDir, { recursive: true }) const record = agentSessionRecordFixture({ ...agentSessionRecordFixture().lease, sessionId: SESSION, runtimeKind: 'native' }) - await writeFile( - join(storeDir, AGENT_SESSION_STORE_FILE_NAME), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: record }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - ) + await seedTestAgentSessionRecordStore(storeDir, { 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 AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const target = await openTestAgentSessionRecordStore(storeDir) expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] }) await target.setSessionTabVisibility(SESSION, true) expect(target.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [SESSION] }) @@ -126,7 +118,7 @@ describe('structured session rollback compatibility', () => { targetReloadedProfile?.success ? targetReloadedProfile.data : null ) ).toEqual([]) - const reloaded = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const reloaded = await openTestAgentSessionRecordStore(storeDir) expect(reloaded.listVisibleSessionIds()).toEqual([SESSION]) expect(reloaded.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( @@ -137,7 +129,7 @@ describe('structured session rollback compatibility', () => { ).toMatchObject({ visibleSessionIds: [SESSION] }) await reloaded.setSessionTabVisibility(SESSION, false) - const afterClose = await AgentSessionRecordStore.open({ directory: storeDir, hostId: 'local' }) + const afterClose = await openTestAgentSessionRecordStore(storeDir) expect(afterClose.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [] }) expect(afterClose.listVisibleSessionIds()).toEqual([]) expect(afterClose.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index d0650d94f495..62b3e385f0c6 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -9,7 +9,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CODEX_SPAWN_TOKEN_ENV } from '../codex/codex-structured-owner-identity' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' import { createStructuredAgentSessionOwnerProbe } from './structured-agent-session-owner-probe' import { @@ -89,8 +90,7 @@ afterEach(async () => { }) function openStore(): Promise { - return AgentSessionRecordStore.open({ - directory: join(stateDirectory, 'agent-sessions'), + return openTestAgentSessionRecordStore(join(stateDirectory, 'agent-sessions'), { hostId: HOST_ID }) } diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index 1cce64dea323..d87a76ccd3a9 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -20,7 +20,8 @@ import { hostTestOperationId, resetHostTestOperationIds } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' -import { AgentSessionRecordStore } from './agent-session-record-store' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { openTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' import { createStructuredClaudeRuntimeAdapter } from './structured-claude-runtime-adapter' import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' @@ -106,10 +107,7 @@ async function reattach() { beforeEach(async () => { resetHostTestOperationIds() directory = await mkdtemp(join(tmpdir(), 'orca-claude-pending-rewind-')) - store = await AgentSessionRecordStore.open({ - directory: join(directory, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(directory, 'store')) claude = fakeClaude({ initSessionId: PROVIDER_SESSION_ID }) adapter = createStructuredClaudeRuntimeAdapter({ store, diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index 981731cd40f1..f24571786ad3 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -18,7 +18,8 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import type { StructuredAgentSessionAdapter } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-adapter' import { StructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-host' import { setStructuredAgentSessionHost } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-registry' -import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store-test-harness' import { RuntimeSubscriptionRegistry } from '../../../src/main/runtime/runtime-subscription-registry' import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire' import { @@ -472,10 +473,7 @@ describe('cross-version structured agent sessions', () => { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) - store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) const host = new StructuredAgentSessionHost({ store, adapter: { @@ -701,10 +699,7 @@ describe('cross-version structured agent sessions', () => { /** Reopens the store from disk and installs a fresh host over the same journal * root — what a process restart actually leaves behind. */ async function bootHost(generation: string): Promise { - store = await AgentSessionRecordStore.open({ - directory: join(root, 'store'), - hostId: 'local' - }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) const host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index dff6b466ec74..b5e7271cc195 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -18,7 +18,8 @@ import { resetHostTestOperationIds } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-host-test-data' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep' -import { AgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store' +import type { AgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store' +import { openTestAgentSessionRecordStore } from '../../src/main/runtime/agent-session-record-store-test-harness' import { useAppStore } from '../../src/renderer/src/store' import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/worktree-agent-activation-gate' import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory' @@ -114,7 +115,7 @@ beforeEach(async () => { clock = NOW resetHostTestOperationIds() closeSession = vi.fn(async () => true) - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) openHost() expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ ok: true @@ -140,7 +141,7 @@ describe('a chat at rest keeps its worktree activatable', () => { it('after an app restart restored it for reading', async () => { await host.flushAllStreamedEvents() - store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' }) + store = await openTestAgentSessionRecordStore(join(root, 'store')) openHost() await host.restoreReadableSessions() expect(host.hasSession(SESSION)).toBe(true) From f3ec1a117020ded62c38c5a7f34dc380a09e7d60 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:14:04 -0700 Subject: [PATCH 05/30] fix(native-chat): start each restore pass from one lease check and stop its bookkeeping at the first failure The restore now runs one reader lease check for the pass and lets each chat re-check and resolve recovery only while the pass is still settled. The first refusal or failed write clears it for the rest of the pass, and every chat is still opened for reading. With another process holding the lock, startup waits on it once in prepare and once in the restore, however many chats are open; a legacy profile waits once more for its tab-index seed. --- ...ured-agent-session-restart-restore.test.ts | 52 ++++++++++++++++ ...tructured-agent-session-restart-restore.ts | 21 ++++--- ...-agent-session-startup-tab-restore.test.ts | 61 +++++++++++++------ 3 files changed, 110 insertions(+), 24 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index c7d6b21ea1b0..0cffa1e32c75 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -165,6 +165,58 @@ describe('restart journal restoration', () => { expect(calls).toEqual(['resolveRecovery', 'open', 'onReadable:restored']) }) + // Each failed bookkeeping call stands for one wait on a held store lock. + describe('once lease bookkeeping fails in a pass', () => { + const records = Array.from( + { length: 8 }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + const restore = ( + bookkeeping: Pick< + Parameters[0], + 'reconcile' | 'resolveRecovery' + > + ) => + restoreStructuredAgentSessionsOnRestart({ + openDeps: NO_OPEN_DEPS, + records, + ...bookkeeping, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined + }) + + /** A failure that takes a while, as a lock wait does, so the chats open at once overlap it. */ + const slowFailure = async (): Promise => { + await new Promise((resolve) => setTimeout(resolve, 5)) + return false + } + + beforeEach(() => restoreRead.mockResolvedValue(null)) + + it('skips it for every chat when the pass check fails, and still opens them all', async () => { + const reconcile = vi.fn(slowFailure) + const resolveRecovery = vi.fn(async () => true) + + await restore({ reconcile, resolveRecovery }) + + expect(reconcile).toHaveBeenCalledOnce() + expect(resolveRecovery).not.toHaveBeenCalled() + expect(restoreRead).toHaveBeenCalledTimes(records.length) + }) + + it('starts no more after the first failed recovery, and still opens every chat', async () => { + const resolveRecovery = vi.fn(slowFailure) + + await restore({ reconcile: async () => true, resolveRecovery }) + + // Only those already started when the first failed: at most one per chat open at once. + expect(resolveRecovery.mock.calls.length).toBeLessThanOrEqual(4) + expect(restoreRead).toHaveBeenCalledTimes(records.length) + }) + }) + it('does not settle again when a second restore finds the session already open', async () => { restoreRead.mockResolvedValue({ session: { journal: {}, params: {}, child: null }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index facc67a86a92..29ea17845d8a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -72,15 +72,22 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( export async function restoreStructuredAgentSessionsOnRestart( input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } ): Promise { - // Once bookkeeping fails in this pass, trying again per chat only waits on the same store again. - let bookkeepingFailed = false + const [first] = input.records + if (!first) { + return + } + // One check for the pass. Each chat checks again while it holds, since another writer can mark + // leases unreconciled mid-pass; after the first failure, retrying per chat only waits on the + // same store again, and the next attach or send settles those chats instead. + let settled = await input.reconcile(first.sessionId) const settleLeases = async (sessionId: string): Promise => { - if (bookkeepingFailed) { - return - } // A session latched in recovery exits here at startup, without waiting for a client. - const settled = (await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId)) - bookkeepingFailed ||= !settled + if ( + settled && + !((await input.reconcile(sessionId)) && (await input.resolveRecovery(sessionId))) + ) { + settled = false + } } await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 77797a5f531b..dcf55f3207c2 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -281,25 +281,30 @@ describe('restoring the chat tabs open at quit', () => { } }) - it('costs two lock waits in all while the lock stays held, however many chats are open', async () => { - const chats = Array.from({ length: 8 }, (_, index) => `chat-${index}-000${index}`) - const records = chats.map((sessionId) => chatRecord(sessionId)) - await seedStore(records, { visible: chats }) - await seedHistory(records) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, published } = startupRuntime({ - afterInstall: () => { - lock.failing = true - } - }) + // Each refused take stands for one lock wait of about 3 s. + it.each([1, 4, 8])( + 'waits on a held lock once per startup step with %i chats open', + async (count) => { + const chats = Array.from({ length: count }, (_, index) => `chat-${index}-000${index}`) + const records = chats.map((sessionId) => chatRecord(sessionId)) + await seedStore(records, { visible: chats }) + await seedHistory(records) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + afterInstall: () => { + lock.failing = true + } + }) - await runtime.prepareStructuredAgentSessionStartupRestoration() - await runtime.restoreStructuredAgentSessionTabs() + await runtime.prepareStructuredAgentSessionStartupRestoration() + const prepared = lock.refused + await runtime.restoreStructuredAgentSessionTabs() - expect(published()).toHaveLength(8) - // One for the startup reconcile, one for the restore's; the restore writes no tab. - expect(lock.refused).toBe(2) - }) + expect(published()).toHaveLength(count) + expect(prepared).toBe(1) + expect(lock.refused - prepared).toBe(1) + } + ) describe('on a legacy profile, with no tab index yet', () => { const legacyChats = () => [ @@ -328,6 +333,28 @@ describe('restoring the chat tabs open at quit', () => { expect(writes.visibility).not.toHaveBeenCalled() }) + it('waits on a held lock once more, for that one write', async () => { + const records = legacyChats() + await seedStore(records) + await seedHistory(records) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, published } = startupRuntime({ + profileChats: [CHAT_A, CHAT_B], + afterInstall: () => { + lock.failing = true + } + }) + + await runtime.prepareStructuredAgentSessionStartupRestoration() + const prepared = lock.refused + await runtime.restoreStructuredAgentSessionTabs() + + expect(published()).toHaveLength(2) + expect(prepared).toBe(1) + // The restore's lease check and the seed. + expect(lock.refused - prepared).toBe(2) + }) + it('still lists the chats when that write fails, and leaves the index to seed again', async () => { const records = legacyChats() const { path } = await seedStore(records) From a77e8245d166bc319251d79d5c04d5044377e4f8 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:27:42 -0700 Subject: [PATCH 06/30] docs(native-chat): correct restore comments and a test name to match the final design --- .../structured-agent-session-restart-reconcile.ts | 5 +++-- ...tructured-agent-session-startup-reconcile-failure.test.ts | 4 ++-- src/main/runtime/agent-session-tab-table.ts | 1 - ...rca-runtime-restore-structured-agent-session-tabs-once.ts | 2 +- .../structured-agent-session-startup-tab-restore.test.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index be2d0b6c1513..23f0e0c6bfae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -45,8 +45,9 @@ export function createRestartReconciler(deps: { } } -/** Reports each distinct failure once, until `clear` says the bookkeeping settled again. Every read - * re-runs a failing reconcile, so without this a failing store would log on every chat list. */ +/** Reports each distinct failure once, until `clear` says the bookkeeping settled again: the + * startup check, the restore pass and a restore retried after a failed journal open would each + * log the same store failure. */ export type ReaderBookkeepingFailures = { report: (failure: unknown) => void clear: () => void diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 8033843cd41e..ed18328663c5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -90,7 +90,7 @@ async function relaunch( mintSpawnToken: () => 'spawn-next', probeOwner, stopOwnerProcess: () => { - throw new Error('a read must not stop an owner') + throw new Error('an owner not proven alive must not be stopped') }, now: () => NOW, onLeaseReconcileFailure @@ -180,7 +180,7 @@ it('restores a chat for reading from a store a newer Orca wrote', async () => { expect(await readFile(path)).toEqual(bytes) }) -// A chat whose owner could not be proven gone is left recovering; resolving that is the writer's. +// A chat whose owner could not be proven gone is left recovering; the next attach or send retries it. it('restores a chat for reading when resolving its recovery cannot write the store', async () => { const { host, store, onLeaseReconcileFailure } = await relaunch(undefined, async () => ({ outcome: 'indeterminate', diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 840356cf0cf0..72551d563cab 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -131,7 +131,6 @@ export function setAgentSessionTabVisibility( } } -/** Shows each session that still has a record. */ export function showAgentSessionTabs( state: AgentSessionStoreState, sessionIds: readonly string[] diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 1eea896b5dce..10dd920b253b 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -84,7 +84,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu targets, restored.map((session) => session.sessionId) ) - // The store already lists every tab projected here, so the restore writes nothing else. + // Past the seed, projecting records nothing. for (const replacement of host?.conversationReplacements?.() ?? []) { this.replaceStructuredAgentSessionTab(replacement) } diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index dcf55f3207c2..61fa020e4276 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -355,7 +355,7 @@ describe('restoring the chat tabs open at quit', () => { expect(lock.refused - prepared).toBe(2) }) - it('still lists the chats when that write fails, and leaves the index to seed again', async () => { + it('still lists the chats when that write fails, and leaves the index absent', async () => { const records = legacyChats() const { path } = await seedStore(records) await seedHistory(records) From ed88d256bf8d916e2d7342a48f88433640684289 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:41:03 -0700 Subject: [PATCH 07/30] test: address the record-store harness by the host's state directory The harness took the store's own folder, so each caller picked one (join(root, 'store'), or 'agent-sessions' where a test read the store the runtime owns). A later change that moves the store into the state directory's journal database could not tell those apart, and would have had to edit every caller again. Every harness function now takes the state directory, the one the test's journal database and recovery capsule already live in, and keeps the store in the same subfolder the runtime uses. Callers pass that directory; store-only tests pass their temp directory unchanged. Format tests that share a directory with harness calls take the file path from testAgentSessionStoreFilePath. The folder name moves from a private constant in the runtime to AGENT_SESSION_STORE_DIR_NAME beside the store's file name, so the harness shares it without importing the runtime. Its value and every path built from it are unchanged. --- .../claude-api-retry-idle-sweep.test.ts | 2 +- .../codex-structured-question-order.test.ts | 2 +- ...-agent-session-accept-then-deliver.test.ts | 2 +- ...-agent-session-acquisition-options.test.ts | 26 +++++----- ...tured-agent-session-adopted-import.test.ts | 4 +- ...ured-agent-session-append-delivery.test.ts | 2 +- ...-agent-session-claude-compact-stop.test.ts | 2 +- ...-agent-session-claude-echo-working.test.ts | 2 +- ...-agent-session-claude-option-queue.test.ts | 2 +- ...d-agent-session-claude-queued-stop.test.ts | 2 +- ...red-agent-session-claude-root-exit.test.ts | 5 +- ...tured-agent-session-codex-stop-row.test.ts | 2 +- ...ed-agent-session-conversation-stop.test.ts | 2 +- ...ured-agent-session-crash-mid-start.test.ts | 2 +- ...tured-agent-session-crash-turn-end.test.ts | 5 +- ...ession-failed-create-owner-verdict.test.ts | 2 +- ...session-failed-create-sink-release.test.ts | 2 +- ...tured-agent-session-grouped-prompt.test.ts | 2 +- ...ed-agent-session-host-test-abandon.test.ts | 2 +- ...uctured-agent-session-host-test-harness.ts | 2 +- .../structured-agent-session-host.test.ts | 3 +- ...ured-agent-session-import-mismatch.test.ts | 3 +- ...ured-agent-session-late-settlement.test.ts | 2 +- ...gent-session-legacy-handoff-record.test.ts | 6 +-- ...ssion-main-agent-working-agreement.test.ts | 2 +- ...ed-agent-session-option-settlement.test.ts | 2 +- ...ed-agent-session-owed-work-release.test.ts | 2 +- ...ent-session-pre-spawn-first-answer.test.ts | 2 +- ...nt-session-processless-reservation.test.ts | 14 +++-- ...gent-session-provider-child-record.test.ts | 2 +- ...red-agent-session-provider-restore.test.ts | 5 +- ...red-agent-session-provider-started.test.ts | 2 +- ...session-queued-message-rig.test-fixture.ts | 2 +- ...tured-agent-session-recovery-exits.test.ts | 6 +-- ...ctured-agent-session-refusal-retry.test.ts | 2 +- .../structured-agent-session-rest-test-rig.ts | 4 +- ...ssion-restart-interruption-test-harness.ts | 2 +- ...session-restart-status-publication.test.ts | 5 +- ...tured-agent-session-rewind-at-rest.test.ts | 6 +-- .../structured-agent-session-rewind.test.ts | 2 +- ...agent-session-send-open-stale-turn.test.ts | 3 +- ...red-agent-session-send-preparation.test.ts | 2 +- ...session-send-restarts-failed-start.test.ts | 2 +- ...agent-session-settled-attach-retry.test.ts | 6 +-- ...agent-session-start-failure-writer.test.ts | 2 +- ...red-agent-session-surface-lifetime.test.ts | 6 +-- ...sion-view-start-after-failed-start.test.ts | 2 +- ...t-session-wedged-profile-migration.test.ts | 7 ++- .../structured-conversation-command.test.ts | 2 +- ...-session-older-build-lease.test-fixture.ts | 8 +-- .../agent-session-record-store-file.ts | 4 ++ ...-record-store-legacy-handoff-lease.test.ts | 18 ++++--- ...ent-session-record-store-tab-table.test.ts | 6 +-- ...agent-session-record-store-test-harness.ts | 52 +++++++++++++------ .../agent-session-record-store.test.ts | 16 +++--- .../rpc/methods/agent-launch-replay.test.ts | 6 +-- ...ured-agent-session-adoption-replay.test.ts | 2 +- .../structured-agent-session-hold.test.ts | 2 +- .../methods/structured-chat-tab-table.test.ts | 10 ++-- ...ructured-agent-session-integration.test.ts | 2 +- ...ent-session-rollback-compatibility.test.ts | 19 ++++--- .../structured-agent-session-runtime.ts | 12 ++--- ...nt-session-spawn-token-descendants.test.ts | 2 +- .../structured-claude-pending-rewind.test.ts | 2 +- ...ss-version-agent-session-wire.unit.test.ts | 4 +- ...-chat-owner-status-activation.unit.test.ts | 4 +- 66 files changed, 180 insertions(+), 167 deletions(-) diff --git a/src/main/claude/claude-api-retry-idle-sweep.test.ts b/src/main/claude/claude-api-retry-idle-sweep.test.ts index 97b0734cb8f4..7f3511930292 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -53,7 +53,7 @@ beforeEach(async () => { sink = null clock = NOW closeSession = vi.fn(async () => true) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const adapter: StructuredAgentSessionAdapter = { acquire: async ({ fence, spawnToken, events }) => { sink = events ?? null diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index cfc84ab36c93..069fe4eb048e 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -91,7 +91,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async ({ commit }) => commit()), setOption: vi.fn(async () => undefined) } - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts index 4b8ff7c73ae1..b7371ee40f36 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-accept-then-deliver.test.ts @@ -110,7 +110,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) await startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts index 25cc6dd568be..a0d2f0fdb7dd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-acquisition-options.test.ts @@ -117,7 +117,7 @@ function expectSettledAttachLease(record: AgentSessionRecord | null): void { describe('structured session acquisition options', () => { it('samples provider history before acquiring a replacement child', async () => { root = await mkdtemp(join(tmpdir(), 'orca-history-before-acquire-')) - const initialStore = await openTestAgentSessionRecordStore(join(root, 'store')) + const initialStore = await openTestAgentSessionRecordStore(root) let childAcquired = false const historyWindow = (): ProviderHistoryWindow => ({ items: [], @@ -179,7 +179,7 @@ describe('structured session acquisition options', () => { fence: 1 }) await firstJournal!.close() - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 @@ -213,7 +213,7 @@ describe('structured session acquisition options', () => { it('persists create defaults before the first provider acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-options-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ origin: 'created' }) const options = { model: 'gpt-5.6-sol', effort: 'medium', fastMode: 'false' } const recordPhase = vi.fn() @@ -244,7 +244,7 @@ describe('structured session acquisition options', () => { it('replays a create retried after the host re-resolved different options', async () => { root = await mkdtemp(join(tmpdir(), 'orca-create-retry-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ origin: 'created' }) const attempt = async (options: Readonly>, spawnToken: string) => performAttach({ @@ -275,8 +275,7 @@ describe('structured session acquisition options', () => { it('persists provider options before proving a resumed legacy record', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-')) - const storeDir = join(root, 'store') - const store = await openTestAgentSessionRecordStore(storeDir) + const store = await openTestAgentSessionRecordStore(root) const created = await performAttach({ store, @@ -302,7 +301,7 @@ describe('structured session acquisition options', () => { now: NOW }) - const resumedStore = await openTestAgentSessionRecordStore(storeDir) + const resumedStore = await openTestAgentSessionRecordStore(root) await resumedStore.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: NOW + 1 @@ -331,7 +330,7 @@ describe('structured session acquisition options', () => { }) expect(resumed).toMatchObject({ ok: true }) - const reopened = await openTestAgentSessionRecordStore(storeDir) + const reopened = await openTestAgentSessionRecordStore(root) expect(reopened.getRecord(SESSION)?.options).toEqual({ approvalPolicy: 'on-request', personality: 'concise', @@ -343,7 +342,7 @@ describe('structured session acquisition options', () => { it('clears a rejected Fast restore instead of retaining the prior encoded value', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-fast-restore-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter({ origin: 'created', options: { current: { model: 'gpt-standard' }, models: [] }, @@ -375,7 +374,7 @@ describe('structured session acquisition options', () => { it('releases an acquisition when provider options cannot be read', async () => { root = await mkdtemp(join(tmpdir(), 'orca-acquisition-options-failure-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const releaseAcquisition = vi.fn(async () => true) const failingAdapter: StructuredAgentSessionAdapter = { ...adapter({ origin: 'created' }), @@ -426,8 +425,7 @@ describe('structured session acquisition options', () => { ] as const)('atomically settles the lease and operation after %s', async (_case, cleanup) => { const exitProven = cleanup === true root = await mkdtemp(join(tmpdir(), `orca-acquisition-${failurePoint}-`)) - const storeDir = join(root, 'store') - const store = await openTestAgentSessionRecordStore(storeDir) + const store = await openTestAgentSessionRecordStore(root) const base = adapter({ origin: 'created', options: { current: { model: 'gpt-5.6-terra' }, models: [] } @@ -514,7 +512,7 @@ describe('structured session acquisition options', () => { exitProven ? injected.message : 'agent_session_acquisition_exit_unproven' )) - const reopened = await openTestAgentSessionRecordStore(storeDir) + const reopened = await openTestAgentSessionRecordStore(root) const failedRecord = reopened.getRecord(SESSION) expectSettledAttachLease(failedRecord) expect( @@ -577,7 +575,7 @@ describe('structured session acquisition options', () => { describe('the tab a create reserves', () => { async function openStore() { root = await mkdtemp(join(tmpdir(), 'orca-surface-tab-id-')) - return openTestAgentSessionRecordStore(join(root, 'store')) + return openTestAgentSessionRecordStore(root) } function attachWith(store: AgentSessionRecordStore, surfaceTabId?: string) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index df5f46500958..6bb43fb9d31d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts @@ -120,7 +120,7 @@ async function attach( sessionAdapter: StructuredAgentSessionAdapter, onAttached: AttachFlowInput['onAttached'] = () => {} ) { - store ??= await openTestAgentSessionRecordStore(join(root!, 'store')) + store ??= await openTestAgentSessionRecordStore(root!) return performAttach({ store, adapter: sessionAdapter, @@ -231,7 +231,7 @@ describe('adopting a provider conversation on create', () => { root = await mkdtemp(join(tmpdir(), 'orca-adopt-host-failure-')) const transcriptPath = join(root, 'rollout.jsonl') await writeCodexRollout(transcriptPath, 'valid source') - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts index 0585b330420e..bda7cc9330f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-append-delivery.test.ts @@ -126,7 +126,7 @@ beforeEach(async () => { acquisitionGeneration: `generation-${++generation}`, providerChildPhase: 'starting' as const })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts index 57ad992fd771..b456bacac26e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-compact-stop.test.ts @@ -68,7 +68,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => adapterNow }) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts index bb4022924c39..1ba4f13e4ea8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-echo-working.test.ts @@ -64,7 +64,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts index c51ce418ec8f..6398d96ab0a1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-option-queue.test.ts @@ -81,7 +81,7 @@ beforeEach(async () => { resetHostTestOperationIds() activeModel = DEFAULT_MODEL optionWritable = Promise.resolve() - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts index 8ccc268ee241..f78be712f7cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-queued-stop.test.ts @@ -80,7 +80,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts index 59f8aad20cc2..7cfd7fe86234 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-claude-root-exit.test.ts @@ -31,7 +31,8 @@ describe('Claude root-exit stop', () => { it('releases a captured live claim after the provider root exits', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-claude-root-exit-')) roots.push(root) - const store = await openTestAgentSessionRecordStore(root) + const stateDirectory = join(root, 'journal') + const store = await openTestAgentSessionRecordStore(stateDirectory) const claude = fakeClaude({ unprovenCloseVerdict: { root: 'exited', tree: 'unverifiable' } }) @@ -78,7 +79,7 @@ describe('Claude root-exit stop', () => { }) const journal = await journals.open({ identity: { ...identityFor(), hostId: 'local', workspaceId: 'folder-1' }, - stateDirectory: join(root, 'journal') + stateDirectory }) const close = vi.spyOn(journal, 'close') const publishStatus = vi.fn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts index f4d19388029c..8833ac4ee31b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-codex-stop-row.test.ts @@ -49,7 +49,7 @@ beforeEach(async () => { ) return {} } - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapterFor(codex), { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts index 4eeff810dd4a..2eeae4787ce5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-stop.test.ts @@ -49,7 +49,7 @@ beforeEach(async () => { dispatch = vi.fn(async () => ({ state: 'admitted' as const })) cancelTurn = vi.fn(async () => ({ cancelled: true })) awaitStarted = vi.fn(async () => undefined) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts index 87d7ba1a79e4..22295ea6e864 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-mid-start.test.ts @@ -59,7 +59,7 @@ async function crash(dying: AgentSessionRecordStore): Promise { } function openStore(generation: HostGeneration): Promise { - return openTestAgentSessionRecordStore(join(generationRoot(generation), 'store')) + return openTestAgentSessionRecordStore(generationRoot(generation)) } /** A Codex adapter whose child spawns, reports its pid the way the real connection does, and then diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts index 54bd2789c00f..c7f8f09631a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-crash-turn-end.test.ts @@ -99,9 +99,8 @@ function crashedClaudeRecord(): AgentSessionRecord { } async function seedCrashedStore(): Promise { - const directory = join(root, 'store') - await seedTestAgentSessionRecordStore(directory, { records: [crashedClaudeRecord()] }) - store = await openTestAgentSessionRecordStore(directory) + await seedTestAgentSessionRecordStore(root, { records: [crashedClaudeRecord()] }) + store = await openTestAgentSessionRecordStore(root) } /** A running turn whose only row after its start is a Bash call that never reported back, for a diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts index 8d0f31367fb7..d1a45fc469a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-owner-verdict.test.ts @@ -49,7 +49,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts index 9e88829aad31..06538d783109 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-failed-create-sink-release.test.ts @@ -45,7 +45,7 @@ beforeEach(async () => { }, acquisitionGeneration: `generation-${fence}` })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts index 29f6eabf703c..7c37b9542536 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-grouped-prompt.test.ts @@ -131,7 +131,7 @@ beforeEach(async () => { } })) answerPrompt = vi.fn(async ({ commit }) => commit()) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts index 0d1dfc5d6cb1..4d064cbdfaa6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.test.ts @@ -25,7 +25,7 @@ const CALLER = { callerKey: 'client-1' } describe('abandoning a structured agent-session host', () => { it('waits for the restart the delivery loop woke for an accepted send', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-abandon-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) // Holds the restart inside its provider acquisition, so the point teardown must not run past // is exact rather than a timing window. let gate: Promise | null = null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index f3528123f998..1a701f5b13cc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -148,7 +148,7 @@ beforeEach(async () => { cancelTurn = vi.fn(async () => ({ cancelled: true })) answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index 78454b675026..46bd84905921 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -4,7 +4,6 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { join } from 'node:path' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' @@ -561,7 +560,7 @@ describe('restart', () => { adapterOverrides: Partial = {}, stopOwnerProcess?: StructuredAgentSessionHostDeps['stopOwnerProcess'] ) { - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), ...adapterOverrides }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts index 8d96bf346320..a173a3ad6619 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -2,7 +2,6 @@ // 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 { join } from 'node:path' 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' @@ -92,7 +91,7 @@ async function relaunchWithMismatchedCopy(): Promise recursive: true, filter: (source) => !source.includes('.lock') }) - const store = await openTestAgentSessionRecordStore(join(relaunched, 'store')) + const store = await openTestAgentSessionRecordStore(relaunched) const host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 5383914012a9..510384f3e484 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -93,7 +93,7 @@ beforeEach(async () => { resetHostTestOperationIds() dispatch = vi.fn(async () => accepted()) closeSession = vi.fn(async () => true) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index e936ef337a2a..706d0d8fc3f3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -73,8 +73,8 @@ async function persistFromOlderBuild(lease: OlderBuildLease): Promise { const attached = store.getRecord(SESSION)?.lease await host.flushAllStreamedEvents() // Over the attached owner: the older build's stage or terminal owner kept it from releasing. - await writeOlderBuildLease(join(root, 'store'), SESSION, { ...attached, ...lease }) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + await writeOlderBuildLease(root, SESSION, { ...attached, ...lease }) + store = await openTestAgentSessionRecordStore(root) acquire.mockClear() openHost() } @@ -131,7 +131,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) openHost() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts index 5e53dadf8beb..333b5bf3e019 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-main-agent-working-agreement.test.ts @@ -59,7 +59,7 @@ beforeEach(async () => { listed = undefined // Written, and the provider has neither opened a turn for it nor answered it. dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index ea7abc170c35..1ec8f2784a28 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -121,7 +121,7 @@ beforeEach(async () => { closeSessionExit = true dispatchedModels.length = 0 const accountHome = join(root, 'codex-home') - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) router = adapter() host = new StructuredAgentSessionHost({ store, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index ab77dea332c9..6ad1506e2aa3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -86,7 +86,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index d3fb132954f7..db9406785f87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -66,7 +66,7 @@ function createParams(): AgentSessionAttachParams { /** The thrown first answer as the wire sends it, and the ledger's replay of the same operation. */ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { root = await mkdtemp(join(tmpdir(), 'orca-pre-spawn-first-answer-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const unused = async (): Promise => { throw new Error('not reached before a spawn') } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index b6df344d01ac..9df12ea96fde 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -68,7 +68,7 @@ function attachParams( describe('processless structured session reservation', () => { it('refuses an adapter that declares no create support before reserving a lease', async () => { root = await mkdtemp(join(tmpdir(), 'orca-unsupported-attach-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const reserveOwner = vi.spyOn(store, 'reserveOwner') const acquire = vi.fn() const adapter = { @@ -106,7 +106,7 @@ describe('processless structured session reservation', () => { it('refuses a replay when adapter support drifts after durable reservation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-replay-support-drift-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -153,7 +153,7 @@ describe('processless structured session reservation', () => { it('releases a new reservation when support drifts before acquisition', async () => { root = await mkdtemp(join(tmpdir(), 'orca-support-drift-reservation-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const supportsCreate = vi .fn>() .mockReturnValueOnce(true) @@ -205,8 +205,7 @@ describe('processless structured session reservation', () => { it('settles a pre-spawn failure and its processless evidence in one durable transaction', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-reservation-')) - const storeDir = join(root, 'store') - const store = await openTestAgentSessionRecordStore(storeDir) + const store = await openTestAgentSessionRecordStore(root) const adapter = { acquire: vi.fn(async () => { throw new AgentSessionPreSpawnError(new Error('workspace no longer exists')) @@ -244,7 +243,7 @@ describe('processless structured session reservation', () => { }) expect(store.listOperationRows()[0]?.outcome).toMatchObject({ status: 'failed' }) - const reopened = await openTestAgentSessionRecordStore(storeDir) + const reopened = await openTestAgentSessionRecordStore(root) await reopened.reconcileOnRestart({ probe: async () => ({ outcome: 'indeterminate', reason: 'no owner to probe' }), now: NOW + 1 @@ -258,8 +257,7 @@ describe('processless structured session reservation', () => { it('does not rerun a settled pre-spawn failure and admits a fresh operation', async () => { root = await mkdtemp(join(tmpdir(), 'orca-processless-retry-')) - const storeDir = join(root, 'store') - const store = await openTestAgentSessionRecordStore(storeDir) + const store = await openTestAgentSessionRecordStore(root) const adapter = { acquire: vi .fn() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index c302a5771561..75061435a1fe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -116,7 +116,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) startHost() expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) await host.close(SESSION) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 3466c5e4dfd6..411e148c586c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -78,8 +78,7 @@ describe('structured session provider restore', () => { it('restores a durable Claude session tab with its recorded provider', async () => { root = await mkdtemp(join(tmpdir(), 'orca-provider-restore-')) resetHostTestOperationIds() - const storeDirectory = join(root, 'store') - const store = await openTestAgentSessionRecordStore(storeDirectory) + const store = await openTestAgentSessionRecordStore(root) const host = createHost(store) const attached = await host.attach( { callerKey: 'client-1' }, @@ -92,7 +91,7 @@ describe('structured session provider restore', () => { ) expect(attached).toMatchObject({ ok: true }) - const reopenedStore = await openTestAgentSessionRecordStore(storeDirectory) + const reopenedStore = await openTestAgentSessionRecordStore(root) const restarted = createHost(reopenedStore, async () => ({ outcome: 'indeterminate', reason: 'read does not need ownership' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 9b3cd4bc378a..ff4b5e9c4ee8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -73,7 +73,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, // The production router is what declares create support; the bare adapter only knows locations. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts index bb86b5e39c1d..9aed0d4acb84 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-message-rig.test-fixture.ts @@ -52,7 +52,7 @@ export async function createQueuedMessageTestRig() { providerIdentity: null })) let events: StructuredAgentSessionEventSink | undefined - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 66723f45654a..f69fbad10316 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -93,7 +93,7 @@ function openHost(overrides: Partial = {}): void async function reopenStore(): Promise { await abandonStructuredAgentSessionHost(host) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) } beforeEach(async () => { @@ -114,7 +114,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) openHost() }) @@ -297,7 +297,7 @@ describe('recovery exits', () => { const outgoingHost = host const outgoingStore = store supersededHosts.add(outgoingHost) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) const realProbe = createStructuredAgentSessionOwnerProbe('local') let overlapDriven = false openHost({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 9d84252472b5..09ba97ee8e31 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -59,7 +59,7 @@ function operationId(timestamp = NOW): string { async function createHarness(options: { attached?: boolean } = {}) { const root = await mkdtemp(join(tmpdir(), 'orca-refusal-oracle-')) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const setOption = vi.fn(async () => undefined) const adapter: StructuredAgentSessionAdapter = { acquire: async ({ fence }) => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index a95b1fed8683..0c890767ac22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -127,7 +127,7 @@ export async function createRestTestRig( const clock = { now: HOST_TEST_NOW } const statusEvents: AgentSessionStatusEvent[] = [] const sink = { publish: vi.fn(), forget: vi.fn() } - let store = await openTestAgentSessionRecordStore(join(root, 'store')) + let store = await openTestAgentSessionRecordStore(root) const adapter: RestTestAdapter = { acquire: vi.fn(async ({ fence, spawnToken }) => ({ acquisitionGeneration: `generation-${++generations}`, @@ -178,7 +178,7 @@ export async function createRestTestRig( sink, restart: async (overrides = {}) => { await rig.host.flushAllStreamedEvents().catch(() => undefined) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) rig.store = store rig.host = hostFor(overrides) rig.host.subscribeStatus({ id: 'status', emit: (event) => statusEvents.push(event) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index 7f425f61a7ea..3bf122afde3a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -116,7 +116,7 @@ export async function interruptedRestart( } await previous.host.flushStreamedEvents(SESSION) await previous.host.flushAllStreamedEvents() - const store = await openTestAgentSessionRecordStore(join(previous.root, 'store')) + const store = await openTestAgentSessionRecordStore(previous.root) const closeSession = vi.fn(async () => true) // The relaunch comes after the quit that recorded the offer. const clock = { now: NOW + 1 } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 601eed15c3e3..35425a70afe8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -92,8 +92,7 @@ function sendEnvelope( async function restartWithPersistedTurn(): Promise { root = await mkdtemp(join(tmpdir(), 'orca-restart-status-')) resetHostTestOperationIds() - const directory = join(root, 'store') - const store = await openTestAgentSessionRecordStore(directory) + const store = await openTestAgentSessionRecordStore(root) const host = createHost(store) expect(await host.attach(CALLER, hostTestAttachParams(null))).toMatchObject({ ok: true }) const body = hostTestMessage('persisted conversation') @@ -104,7 +103,7 @@ async function restartWithPersistedTurn(): Promise { // Delivered, not just accepted: a message still queued at the restart was never a request. await host.waitForSendSettlement(SESSION, sent.value.clientMessageId) await host.flushAllStreamedEvents() - return createHost(await openTestAgentSessionRecordStore(directory)) + return createHost(await openTestAgentSessionRecordStore(root)) } afterEach(async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index c1fb0009346f..b34de39b74f1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -107,7 +107,7 @@ beforeEach(async () => { } })) directory = await mkdtemp(join(tmpdir(), 'orca-rewind-rest-')) - store = await openTestAgentSessionRecordStore(join(directory, 'store')) + store = await openTestAgentSessionRecordStore(directory) host = openHost() }) @@ -172,7 +172,7 @@ async function interruptedRewindAtRest(): Promise { ) expect(store.getRecord(SESSION)?.rewind).toMatchObject({ phase: 'prepared' }) await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(join(directory, 'store')) + store = await openTestAgentSessionRecordStore(directory) host = openHost() } @@ -207,7 +207,7 @@ describe('a rewind asked of a chat at rest (P2-23)', () => { await host.flushStreamedEvents(SESSION) const epoch = (await host.journalSnapshot(SESSION)).cursor.epoch await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(join(directory, 'store')) + store = await openTestAgentSessionRecordStore(directory) host = openHost() const before = acquires rewindSupport.mockReturnValue({ supported: false, reason: 'history-not-paginated' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index 8232d287f63f..35e7d5af2e27 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -54,7 +54,7 @@ beforeEach(async () => { }) acquires = [] directory = await mkdtemp(join(tmpdir(), 'orca-rewind-')) - store = await openTestAgentSessionRecordStore(join(directory, 'store')) + store = await openTestAgentSessionRecordStore(directory) adapter = { supportsCreate: (_location, agent) => agent === 'codex', supportsLocation: () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index e5a8388f7794..c85c628d9175 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -5,7 +5,6 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa // new child starts: a start that then fails would leave the turn running for every reader. import { cp, rm } from 'node:fs/promises' -import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' @@ -66,7 +65,7 @@ async function relaunchAfterCrashMidTurn( recursive: true, filter: (source) => !source.includes('.lock') }) - const store = await openTestAgentSessionRecordStore(join(relaunched, 'store')) + const store = await openTestAgentSessionRecordStore(relaunched) const acquire = vi.fn(async () => { throw new Error('claude: command not found') }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 8fc9cbbf75f1..fbe81be21ac1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -69,7 +69,7 @@ beforeEach(async () => { ordinal: dispatch.mock.calls.length } })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index c6f387a68b1c..21e50e4dca60 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -144,7 +144,7 @@ beforeEach(async () => { providerChildPhase: 'starting' as const })) dispatch = vi.fn(async () => ({ state: 'admitted' as const })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 462111f14ade..2d868797024c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -110,7 +110,7 @@ beforeEach(async () => { })) releaseAcquisition = vi.fn(async () => true) dispatch = vi.fn(async () => accepted()) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), @@ -268,7 +268,7 @@ describe('settled attach retry', () => { }) await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), @@ -325,7 +325,7 @@ describe('settled attach retry', () => { await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 517d136b581f..e5527d1a5afb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -118,7 +118,7 @@ beforeEach(async () => { awaitStarted = vi.fn( () => new Promise((resolve) => (settleStart = resolve)) ) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index c25c92d6affb..a06c70bf30ad 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -92,7 +92,7 @@ function openHost( /** A fresh app generation over the same durable store, with its owner proven gone. */ async function reboot(): Promise { await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) openHost(async () => ({ outcome: 'pid-absent' })) acquire.mockClear() closeSession.mockClear() @@ -232,7 +232,7 @@ beforeEach(async () => { surface: 'rejection' }) })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) openHost() }) @@ -401,7 +401,7 @@ describe('startup', () => { const beforeRestart = store.getRecord(SESSION) await abandonStructuredAgentSessionHost(host) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) openHost(async () => ({ outcome: 'pid-absent' })) await host.restoreReadableSessions() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index ac773034ced6..3d38c1cb38d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -82,7 +82,7 @@ beforeEach(async () => { readProcessStartTime: async () => 1_700_000_000_000, now: () => NOW }) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 72d88caa226d..3ae6da5fa493 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -114,9 +114,8 @@ function wedgedRecord(overrides: WedgeOverrides): PersistedAgentSessionRecord { } async function seedStore(record: PersistedAgentSessionRecord): Promise { - const directory = join(root, 'store') - await seedTestAgentSessionRecordStore(directory, { records: [record] }) - store = await openTestAgentSessionRecordStore(directory) + await seedTestAgentSessionRecordStore(root, { records: [record] }) + store = await openTestAgentSessionRecordStore(root) } /** Every recorded owner in these fixtures is long gone; that is the present-time evidence. */ @@ -274,7 +273,7 @@ describe('already-wedged profiles become usable on load', () => { expect(acquire).not.toHaveBeenCalled() await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) openHost() await host.restoreReadableSessions() diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts index 220b12f1d4a3..89b07045b3d5 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-command.test.ts @@ -62,7 +62,7 @@ const generationRoot = () => join(directory, `generation-${generation}`) let ownerProbe: AgentSessionOwnerProbe = { outcome: 'pid-absent' } async function openHost(): Promise { - store = await openTestAgentSessionRecordStore(join(generationRoot(), 'store')) + store = await openTestAgentSessionRecordStore(generationRoot()) host = new StructuredAgentSessionHost({ store, adapter, diff --git a/src/main/runtime/agent-session-older-build-lease.test-fixture.ts b/src/main/runtime/agent-session-older-build-lease.test-fixture.ts index 869f8ddc14c2..d5cc9defe28c 100644 --- a/src/main/runtime/agent-session-older-build-lease.test-fixture.ts +++ b/src/main/runtime/agent-session-older-build-lease.test-fixture.ts @@ -6,18 +6,18 @@ import { /** Rewrites one lease on disk as an older build left it; this build's types cannot express it. */ export async function writeOlderBuildLease( - directory: string, + stateDirectory: string, sessionId: string, fields: Partial ): Promise { - await editPersistedTestAgentSessionStore(directory, (persisted) => { + await editPersistedTestAgentSessionStore(stateDirectory, (persisted) => { persisted.records[sessionId].lease = { ...persisted.records[sessionId].lease, ...fields } }) } export async function readPersistedLease( - directory: string, + stateDirectory: string, sessionId: string ): Promise> { - return (await readPersistedTestAgentSessionStore(directory)).records[sessionId].lease + return (await readPersistedTestAgentSessionStore(stateDirectory)).records[sessionId].lease } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index ef0f4b1ab7f5..0aa32f74f755 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -28,6 +28,10 @@ import { serializeAgentSessionStoreState } from './agent-session-store-serializa export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const +/** In a host's state directory, beside the journal database: one file adjudicates every session's + * lease. */ +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 } diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts index c614cfa5515f..4180af2580b3 100644 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts @@ -12,8 +12,11 @@ import { readPersistedLease, writeOlderBuildLease } from './agent-session-older-build-lease.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + testAgentSessionStoreFilePath +} from './agent-session-record-store-test-harness' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -29,8 +32,7 @@ afterEach(async () => { await rm(directory, { recursive: true, force: true }) }) -const open = (): Promise => - AgentSessionRecordStore.open({ directory, hostId: 'local' }) +const open = (): Promise => openTestAgentSessionRecordStore(directory) async function persistLiveOwner(): Promise { const store = await open() @@ -89,7 +91,7 @@ describe('a lease the removed terminal handoff wrote', () => { handoffStage: 'old-owner-stopped', handoffOperationId: 'op-handoff' }) - const legacy = await readFile(agentSessionStorePath(directory), 'utf-8') + const legacy = await readFile(testAgentSessionStoreFilePath(directory), 'utf-8') const store = await open() expect(store.isSessionUnreadable(SESSION)).toBe(false) @@ -100,7 +102,7 @@ describe('a lease the removed terminal handoff wrote', () => { claimStatus: 'conflicted' }) // An open does not write: another holder of the file mid-restart would read it as a change. - expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(legacy) + expect(await readFile(testAgentSessionStoreFilePath(directory), 'utf-8')).toBe(legacy) // A transaction that changes nothing still writes it. Had the open's revision been taken over // anything but the normalized state, this would read the file as externally changed and @@ -127,8 +129,8 @@ describe('a lease the removed terminal handoff wrote', () => { claimStatus: 'conflicted', handoffStage: 'recovering' }) - const settled = await readFile(agentSessionStorePath(directory), 'utf-8') + const settled = await readFile(testAgentSessionStoreFilePath(directory), 'utf-8') await open() - expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(settled) + expect(await readFile(testAgentSessionStoreFilePath(directory), 'utf-8')).toBe(settled) }) }) diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index 696eb5fd1cb0..c5d4be5319cf 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -13,9 +13,9 @@ import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refu import type { AgentSessionRecordStore } from './agent-session-record-store' import { openTestAgentSessionRecordStore, - readPersistedTestAgentSessionStore + readPersistedTestAgentSessionStore, + testAgentSessionStoreFilePath } from './agent-session-record-store-test-harness' -import { agentSessionStorePath } from './agent-session-record-store-file' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -70,7 +70,7 @@ async function open(): Promise { describe('chat tab table', () => { const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' - const filePath = () => agentSessionStorePath(directory) + const filePath = () => testAgentSessionStoreFilePath(directory) const readFileJson = async () => JSON.parse(await readFile(filePath(), 'utf-8')) it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index d590ecd296d1..74af065cd01b 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -1,13 +1,17 @@ /** * The one way tests open, seed and read back the durable agent-session record store, so a change - * to where or how the store persists is made here rather than in every test that uses it. + * to where or how the store persists is made here rather than in every test that uses it. Every + * function takes the host's state directory, the one its journal database lives in, and keeps the + * store where the runtime does within it. */ import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' import { AgentSessionRecordStore } from './agent-session-record-store' import { + AGENT_SESSION_STORE_DIR_NAME, AGENT_SESSION_STORE_SCHEMA_VERSION, agentSessionStorePath, type RetiredAgentSessionClaimKey @@ -15,6 +19,15 @@ import { const TEST_HOST_ID = 'local' +function storeDirectory(stateDirectory: string): string { + return join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME) +} + +/** The store's file, for the tests that assert on or damage its bytes directly. */ +export function testAgentSessionStoreFilePath(stateDirectory: string): string { + return agentSessionStorePath(storeDirectory(stateDirectory)) +} + /** One committed state of the store, as tests seed it and read it back. */ export type PersistedTestAgentSessionStore = { schemaVersion: number @@ -27,17 +40,20 @@ export type PersistedTestAgentSessionStore = { sessionTabs?: { tabId: string; sessionId: string }[] } -/** Opens, or reopens, the store kept in `directory`: what a fresh app process does at launch. */ +/** Opens, or reopens, the store in `stateDirectory`: what a fresh app process does at launch. */ export function openTestAgentSessionRecordStore( - directory: string, + stateDirectory: string, options: { hostId?: string } = {} ): Promise { - return AgentSessionRecordStore.open({ directory, hostId: options.hostId ?? TEST_HOST_ID }) + return AgentSessionRecordStore.open({ + directory: storeDirectory(stateDirectory), + hostId: options.hostId ?? TEST_HOST_ID + }) } /** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ export async function seedTestAgentSessionRecordStore( - directory: string, + stateDirectory: string, seed: { records: readonly PersistedAgentSessionRecord[] } ): Promise { const persisted: PersistedTestAgentSessionStore = { @@ -48,37 +64,39 @@ export async function seedTestAgentSessionRecordStore( retiredClaimKeys: [], unusableRecords: {} } - await mkdir(directory, { recursive: true }) - await writeFile(agentSessionStorePath(directory), JSON.stringify(persisted), 'utf-8') + await mkdir(storeDirectory(stateDirectory), { recursive: true }) + await writeFile(testAgentSessionStoreFilePath(stateDirectory), JSON.stringify(persisted), 'utf-8') } /** Leaves an empty store a newer build wrote: this build reads it but never writes it. */ -export async function seedTestAgentSessionStoreFromNewerBuild(directory: string): Promise { - await mkdir(directory, { recursive: true }) +export async function seedTestAgentSessionStoreFromNewerBuild( + stateDirectory: string +): Promise { + await mkdir(storeDirectory(stateDirectory), { recursive: true }) await writeFile( - agentSessionStorePath(directory), + testAgentSessionStoreFilePath(stateDirectory), JSON.stringify({ schemaVersion: 99, hostId: TEST_HOST_ID, records: {}, operations: {} }) ) } /** Everything the store has committed, as text: to assert a value never reached disk, or that an * action wrote nothing by comparing two reads. */ -export function readPersistedTestAgentSessionStoreText(directory: string): Promise { - return readFile(agentSessionStorePath(directory), 'utf-8') +export function readPersistedTestAgentSessionStoreText(stateDirectory: string): Promise { + return readFile(testAgentSessionStoreFilePath(stateDirectory), 'utf-8') } export async function readPersistedTestAgentSessionStore( - directory: string + stateDirectory: string ): Promise { - return JSON.parse(await readPersistedTestAgentSessionStoreText(directory)) + return JSON.parse(await readPersistedTestAgentSessionStoreText(stateDirectory)) } /** Changes the committed state behind the store's back, as an older build or a damaged disk would. */ export async function editPersistedTestAgentSessionStore( - directory: string, + stateDirectory: string, edit: (persisted: PersistedTestAgentSessionStore) => void ): Promise { - const persisted = await readPersistedTestAgentSessionStore(directory) + const persisted = await readPersistedTestAgentSessionStore(stateDirectory) edit(persisted) - await writeFile(agentSessionStorePath(directory), JSON.stringify(persisted)) + await writeFile(testAgentSessionStoreFilePath(stateDirectory), JSON.stringify(persisted)) } diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 7ea6174da70d..908f843da981 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -1,6 +1,6 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' import type { @@ -16,13 +16,10 @@ import { editPersistedTestAgentSessionStore, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore, - seedTestAgentSessionStoreFromNewerBuild + seedTestAgentSessionStoreFromNewerBuild, + testAgentSessionStoreFilePath } from './agent-session-record-store-test-harness' import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' -import { - agentSessionStorePath, - AGENT_SESSION_STORE_FILE_NAME -} from './agent-session-record-store-file' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -837,7 +834,7 @@ describe('claim keys, checkpoints, and unreadable rows', () => { checkpoint: { epoch: 1, sequence: 1 }, now: NOW }) - await writeFile(join(directory, AGENT_SESSION_STORE_FILE_NAME), '{ truncated') + await writeFile(testAgentSessionStoreFilePath(directory), '{ truncated') const reopened = await open() expect(reopened.recoveredFromBackup).toBe(true) @@ -861,7 +858,8 @@ describe('claim keys, checkpoints, and unreadable rows', () => { ['invalid retired key', [BAD_KEY_STORE]], ['corrupt in both committed copies', ['{ truncated', '{ also truncated']] ])('fails closed when the store is %s', async (_name, copies) => { - const filePath = agentSessionStorePath(directory) + const filePath = testAgentSessionStoreFilePath(directory) + await mkdir(dirname(filePath), { recursive: true }) await writeFile(filePath, copies[0]) if (copies[1]) { await writeFile(`${filePath}.bak`, copies[1]) diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 2cb953063391..bf90e650d534 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -27,9 +27,9 @@ import { import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore, - readPersistedTestAgentSessionStore + readPersistedTestAgentSessionStore, + testAgentSessionStoreFilePath } from '../../agent-session-record-store-test-harness' -import { agentSessionStorePath } from '../../agent-session-record-store-file' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { RpcContext } from '../core' @@ -434,7 +434,7 @@ describe('an unreadable launch payload costs one replay, never the store', () => /** The whole file, primary and backup: `loadAgentSessionStore` falls through to the backup, and * the backup is a copy of the validated primary, so both carry the same payload in real life. */ async function rewriteRecordedLaunch(payload: unknown): Promise { - const path = agentSessionStorePath(directory) + const path = testAgentSessionStoreFilePath(directory) const file: { operations: Record }> } = JSON.parse( await readFile(path, 'utf-8') ) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index 272cb8bbb92b..90deacab5c3a 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -176,7 +176,7 @@ describe('committed adopting create RPC replay', () => { .mockRejectedValueOnce(new Error('simulated lost tab publication')) .mockResolvedValue(undefined) - const store = await openTestAgentSessionRecordStore(join(root, 'store')) + const store = await openTestAgentSessionRecordStore(root) const sessionAdapter = adapter() host = new StructuredAgentSessionHost({ store, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 008038cb0190..000192cc183d 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -76,7 +76,7 @@ beforeEach(async () => { observedAt: NOW } })) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) host = new StructuredAgentSessionHost({ store, adapter: { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 4d6a313f8b5b..29623f623cd0 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -25,8 +25,10 @@ import { } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionRecordStore } from '../../agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { agentSessionStorePath } from '../../agent-session-record-store-file' +import { + openTestAgentSessionRecordStore, + testAgentSessionStoreFilePath +} from '../../agent-session-record-store-test-harness' import { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' @@ -88,7 +90,7 @@ function providerAdapter(): StructuredAgentSessionAdapter { } async function openHost(): Promise { - store = await openTestAgentSessionRecordStore(join(directory, 'store')) + store = await openTestAgentSessionRecordStore(directory) host = new StructuredAgentSessionHost({ store, adapter: providerAdapter(), @@ -292,7 +294,7 @@ describe('a chat tab across /clear', () => { await host.flushAllStreamedEvents() // An older build rewrites the file from what it read, which drops the table. - const file = agentSessionStorePath(join(directory, 'store')) + const file = testAgentSessionStoreFilePath(directory) const raw = JSON.parse(await readFile(file, 'utf-8')) expect(raw.sessionTabs).toHaveLength(2) delete raw.sessionTabs diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 2e2d87d65471..efc2f786c99e 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -456,7 +456,7 @@ describe('a structured codex session over agentSession.*', () => { EXAMPLE_GATEWAY_TOKEN: 'shell-exported', CODEX_HOME: '/home/dev/.codex' }) - const store = await readPersistedTestAgentSessionStoreText(join(root, 'agent-sessions')) + const store = await readPersistedTestAgentSessionStoreText(root) expect(store).not.toContain('EXAMPLE_GATEWAY_TOKEN') expect(store).not.toContain('"launchEnv"') const stream = await subscribe('sub-first-send') diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index 48a9d33142c2..c5aa604cb65e 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -7,9 +7,9 @@ import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { openTestAgentSessionRecordStore, - seedTestAgentSessionRecordStore + seedTestAgentSessionRecordStore, + testAgentSessionStoreFilePath } from './agent-session-record-store-test-harness' -import { AGENT_SESSION_STORE_FILE_NAME } from './agent-session-record-store-file' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' const WORKSPACE = 'workspace-1' @@ -80,18 +80,17 @@ afterEach(async () => { describe('structured session rollback compatibility', () => { it('keeps the visible session reference through target → base → target', async () => { root = await mkdtemp(join(tmpdir(), 'orca-structured-rollback-')) - const storeDir = join(root, 'agent-store') const record = agentSessionRecordFixture({ ...agentSessionRecordFixture().lease, sessionId: SESSION, runtimeKind: 'native' }) - await seedTestAgentSessionRecordStore(storeDir, { records: [record] }) + 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(storeDir) + const target = await openTestAgentSessionRecordStore(root) expect(target.getVisibleSessionTabIndex()).toEqual({ present: false, sessionIds: [] }) await target.setSessionTabVisibility(SESSION, true) expect(target.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [SESSION] }) @@ -118,18 +117,18 @@ describe('structured session rollback compatibility', () => { targetReloadedProfile?.success ? targetReloadedProfile.data : null ) ).toEqual([]) - const reloaded = await openTestAgentSessionRecordStore(storeDir) + 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( - JSON.parse(await readFile(join(storeDir, AGENT_SESSION_STORE_FILE_NAME), 'utf8')) - ).toMatchObject({ visibleSessionIds: [SESSION] }) + expect(JSON.parse(await readFile(testAgentSessionStoreFilePath(root), 'utf8'))).toMatchObject({ + visibleSessionIds: [SESSION] + }) await reloaded.setSessionTabVisibility(SESSION, false) - const afterClose = await openTestAgentSessionRecordStore(storeDir) + const afterClose = await openTestAgentSessionRecordStore(root) expect(afterClose.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [] }) expect(afterClose.listVisibleSessionIds()).toEqual([]) expect(afterClose.getRecord(SESSION)?.providerHandleChain).toHaveLength(1) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 24735c721cc2..82c9803e2a76 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -42,7 +42,10 @@ import { import { AgentSessionRecordStore } from './agent-session-record-store' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' -import { agentSessionStorePath } from './agent-session-record-store-file' +import { + AGENT_SESSION_STORE_DIR_NAME, + agentSessionStorePath +} from './agent-session-record-store-file' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -58,14 +61,11 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -/** Beside the journal database: one file adjudicates every session's lease. */ -const RECORD_STORE_DIR_NAME = 'agent-sessions' - export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { - const filePath = agentSessionStorePath(join(stateDirectory, RECORD_STORE_DIR_NAME)) + const filePath = agentSessionStorePath(join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME)) return fileExists(filePath) || fileExists(`${filePath}.bak`) } @@ -211,7 +211,7 @@ async function installOnJournal( const envResolvers = createStructuredAgentEnvironmentResolvers(deps) const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers const store = await AgentSessionRecordStore.open({ - directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME), + directory: join(deps.stateDirectory, AGENT_SESSION_STORE_DIR_NAME), hostId: deps.hostId }) let host: StructuredAgentSessionHost | null = null diff --git a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts index 62b3e385f0c6..3cf61c0bf3c9 100644 --- a/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts +++ b/src/main/runtime/structured-agent-session-spawn-token-descendants.test.ts @@ -90,7 +90,7 @@ afterEach(async () => { }) function openStore(): Promise { - return openTestAgentSessionRecordStore(join(stateDirectory, 'agent-sessions'), { + return openTestAgentSessionRecordStore(stateDirectory, { hostId: HOST_ID }) } diff --git a/src/main/runtime/structured-claude-pending-rewind.test.ts b/src/main/runtime/structured-claude-pending-rewind.test.ts index d87a76ccd3a9..137b7ff6961f 100644 --- a/src/main/runtime/structured-claude-pending-rewind.test.ts +++ b/src/main/runtime/structured-claude-pending-rewind.test.ts @@ -107,7 +107,7 @@ async function reattach() { beforeEach(async () => { resetHostTestOperationIds() directory = await mkdtemp(join(tmpdir(), 'orca-claude-pending-rewind-')) - store = await openTestAgentSessionRecordStore(join(directory, 'store')) + store = await openTestAgentSessionRecordStore(directory) claude = fakeClaude({ initSessionId: PROVIDER_SESSION_ID }) adapter = createStructuredClaudeRuntimeAdapter({ store, diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index f24571786ad3..4aaa02037ec3 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -473,7 +473,7 @@ describe('cross-version structured agent sessions', () => { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-cross-version-ai-vault-')) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ store, adapter: { @@ -699,7 +699,7 @@ describe('cross-version structured agent sessions', () => { /** Reopens the store from disk and installs a fresh host over the same journal * root — what a process restart actually leaves behind. */ async function bootHost(generation: string): Promise { - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) const host = new StructuredAgentSessionHost({ store, adapter: adapter(), diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index b5e7271cc195..248adb62c991 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -115,7 +115,7 @@ beforeEach(async () => { clock = NOW resetHostTestOperationIds() closeSession = vi.fn(async () => true) - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) openHost() expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({ ok: true @@ -141,7 +141,7 @@ describe('a chat at rest keeps its worktree activatable', () => { it('after an app restart restored it for reading', async () => { await host.flushAllStreamedEvents() - store = await openTestAgentSessionRecordStore(join(root, 'store')) + store = await openTestAgentSessionRecordStore(root) openHost() await host.restoreReadableSessions() expect(host.hasSession(SESSION)).toBe(true) From 8d5f70f03b9e1e5dfbd4d0b249c02046f21b8bb2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:52:54 -0700 Subject: [PATCH 08/30] refactor(native-chat): keep agent-session records in the chat journal database The record store's records, operation ledger, retired claim keys and chat tab index become tables in agent-session-journal.db (user_version 4). The version-4 migration copies agent-sessions.json in its own transaction and never writes, renames or deletes that file or its .bak. Each store write is one journal transaction over exactly the rows it changed, checked with the load rules; the file lock, the external-change refresh and its hash, the .bak rotation, salvage and the hot-path recovery fence are gone from the store. --- .github/workflows/pr.yml | 1 - .../journal-database-schema.ts | 38 +- .../journal-database.test.ts | 29 +- .../agent-session-journal/journal-database.ts | 90 +++- .../journal-host-database-test-support.ts | 10 +- .../journal-host-database.ts | 30 +- .../journal-open-failure.test.ts | 6 +- .../structured-agent-session-host.ts | 2 + ...d-agent-session-journal-corruption.test.ts | 8 +- ...agent-session-settled-attach-retry.test.ts | 19 +- ...-session-startup-reconcile-failure.test.ts | 103 +++-- .../agent-session-backup-recovery.test.ts | 420 ------------------ .../agent-session-lease-transitions.ts | 24 - .../agent-session-legacy-record-import.ts | 161 +++++++ src/main/runtime/agent-session-record-rows.ts | 270 +++++++++++ .../agent-session-record-store-file.ts | 48 +- ...-record-store-legacy-handoff-lease.test.ts | 134 ------ ...gent-session-record-store-security.test.ts | 61 --- .../agent-session-record-store-security.ts | 40 -- ...ent-session-record-store-tab-table.test.ts | 124 +----- ...agent-session-record-store-test-harness.ts | 154 ++++++- .../agent-session-record-store-write.ts | 48 -- .../agent-session-record-store.test.ts | 185 ++------ .../runtime/agent-session-record-store.ts | 145 +++--- ...-session-record-unsupported-schema.test.ts | 96 ---- ...ent-session-recovery-publish-fault.test.ts | 84 ---- src/main/runtime/agent-session-store-draft.ts | 135 ++++++ .../runtime/agent-session-store-row-rules.ts | 63 +++ .../agent-session-store-serialization.ts | 21 - .../agent-session-store-transaction-queue.ts | 191 -------- .../agent-session-store-transactions.ts | 164 +++++++ src/main/runtime/agent-session-tab-table.ts | 19 +- ...-session-unreadable-record-salvage.test.ts | 167 ------- ...time-close-structured-agent-session-tab.ts | 6 +- ...tore-structured-agent-session-tabs-once.ts | 7 +- .../rpc/methods/agent-launch-replay.test.ts | 20 +- .../methods/structured-chat-tab-table.test.ts | 24 +- ...uctured-agent-session-host-refusal.test.ts | 10 +- ...ured-agent-session-install-failure.test.ts | 5 +- .../structured-agent-session-journal-open.ts | 25 +- ...ent-session-rollback-compatibility.test.ts | 8 +- .../structured-agent-session-runtime.test.ts | 17 +- .../structured-agent-session-runtime.ts | 31 +- ...ed-agent-session-startup-reconcile.test.ts | 92 ++-- ...-agent-session-startup-tab-restore.test.ts | 203 +++++---- src/shared/agent-session-next-fence.ts | 4 +- .../agent-session-provider-handle.test.ts | 38 ++ src/shared/agent-session-provider-handle.ts | 70 ++- ...sion-death-evidence-downgrade.unit.test.ts | 61 --- ...on-unproven-release-downgrade.unit.test.ts | 107 ----- 50 files changed, 1607 insertions(+), 2211 deletions(-) delete mode 100644 src/main/runtime/agent-session-backup-recovery.test.ts create mode 100644 src/main/runtime/agent-session-legacy-record-import.ts create mode 100644 src/main/runtime/agent-session-record-rows.ts delete mode 100644 src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts delete mode 100644 src/main/runtime/agent-session-record-store-security.test.ts delete mode 100644 src/main/runtime/agent-session-record-store-security.ts delete mode 100644 src/main/runtime/agent-session-record-store-write.ts delete mode 100644 src/main/runtime/agent-session-record-unsupported-schema.test.ts delete mode 100644 src/main/runtime/agent-session-recovery-publish-fault.test.ts create mode 100644 src/main/runtime/agent-session-store-draft.ts create mode 100644 src/main/runtime/agent-session-store-row-rules.ts delete mode 100644 src/main/runtime/agent-session-store-serialization.ts delete mode 100644 src/main/runtime/agent-session-store-transaction-queue.ts create mode 100644 src/main/runtime/agent-session-store-transactions.ts delete mode 100644 src/main/runtime/agent-session-unreadable-record-salvage.test.ts delete mode 100644 tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts delete mode 100644 tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 5e06fc6869e2..bec91b0a390c 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -862,7 +862,6 @@ jobs: tests/e2e/cross-version-wire/cross-version-worktree-identity-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-session-tabs-retirement-proof.unit.test.ts tests/e2e/cross-version-wire/agent-session-resume-marker-downgrade.unit.test.ts - tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts tests/e2e/cross-version-wire/cross-version-worktree-ps-verdict.unit.test.ts managed_hook_node18: diff --git a/src/main/native-chat/agent-session-journal/journal-database-schema.ts b/src/main/native-chat/agent-session-journal/journal-database-schema.ts index ffad529cb4b4..e110b9221c44 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -6,12 +6,16 @@ // for a rebuild a partial repair leaves behind (see journal-repair-marker.ts). `journal_imports` // records which per-chat file each chat was copied from (journal-per-session-reimport.ts), and // `journal_set_aside` each chat whose per-chat file is not this build's history and is never read -// again. +// again. The `agent_session_*` tables hold each chat's ownership record, its operation ledger, the +// retired claim keys and the chat tab index (agent-session-record-rows.ts). /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version * (`JournalRow.v`): a newer build can change either alone. A newer version opens read-only here, - * so every change stays additive. Versions below 3 were never released (journal-database.ts). */ -export const JOURNAL_DB_SCHEMA_VERSION = 3 + * so every change stays additive. */ +export const JOURNAL_DB_SCHEMA_VERSION = 4 + +/** The first version a release wrote; 1 and 2 only ever came from development builds. */ +export const JOURNAL_DB_OLDEST_RELEASED_VERSION = 3 export function createJournalTablesSql(): string { return ` @@ -46,3 +50,31 @@ CREATE TABLE IF NOT EXISTS journal_set_aside ( ); ` } + +/** Version 4: the chat records, until then a JSON file beside the database. Each row is one JSON + * value as the file held it, so a row this build cannot read stays byte-identical. */ +export function createAgentSessionRecordTablesSql(): string { + return ` +CREATE TABLE IF NOT EXISTS agent_session_records ( + session_id TEXT PRIMARY KEY, + record_json TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_operations ( + operation_key TEXT PRIMARY KEY, + row_json TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_retired_claim_keys ( + key_id TEXT PRIMARY KEY, + retired_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_tabs ( + tab_id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + position INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS agent_session_store_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); +` +} diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts index 0b479cd9adf6..677efc21c4d7 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.test.ts @@ -8,6 +8,7 @@ import { JOURNAL_BUSY_TIMEOUT_MS, JOURNAL_SIZE_LIMIT_BYTES, journalPragmaNumber, + NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' @@ -66,7 +67,7 @@ afterEach(async () => { describe('the host journal database open', () => { it('creates every table and reads back every load-bearing pragma', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") @@ -97,14 +98,14 @@ describe('the host journal database open', () => { // T5: a newer build's database opens read-only, its rows readable, and is left byte-identical. it('opens a newer user_version read-only without touching the file', async () => { - const seeded = openJournalDatabase(dbPath).db + const seeded = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') insertJournalRow(seeded, 'session-1', epochRow(1)) seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) seeded.close() const before = await digest(dbPath) - const opened = openJournalDatabase(dbPath) + const opened = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS) try { expect(opened.readOnly).toBe(true) expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1') @@ -121,7 +122,7 @@ describe('the host journal database open', () => { it('closes the raw connection when schema setup throws', async () => { const failing = join(root, 'nested', 'agent-session-journal.db') - expect(() => openJournalDatabase(failing)).toThrow() + expect(() => openJournalDatabase(failing, NO_LEGACY_JOURNAL_RECORDS)).toThrow() await expect(stat(`${failing}-wal`)).rejects.toThrow() await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) @@ -130,7 +131,7 @@ describe('the host journal database open', () => { describe('journal row statements', () => { it('serves replay, resume, suffix truncation and an epoch discard', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { @@ -161,7 +162,7 @@ describe('journal row statements', () => { }) it('deletes only the rows no pointer names', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying')) insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live')) @@ -180,7 +181,7 @@ describe('journal row statements', () => { }) it('refuses a duplicate sequence inside one epoch of one chat', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { insertJournalRow(db, 'session-1', epochRow(1)) expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() @@ -192,7 +193,7 @@ describe('journal row statements', () => { }) it('moves the epoch pointer in place', () => { - const db = openJournalDatabase(dbPath).db + const db = openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db try { publishJournalSessionEpoch(db, SESSION, 'epoch-1') publishJournalSessionEpoch(db, SESSION, 'epoch-2') @@ -220,7 +221,9 @@ describe('schema creation', () => { return original.call(this, sql, options) }) - expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published') + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + 'crash before the version is published' + ) pragma.mockRestore() const inspected = new Database(dbPath) @@ -250,8 +253,12 @@ INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) earlier.close() const before = await digest(dbPath) - expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) - expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + `unreleased schema ${version}` + ) + expect(() => openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS)).toThrow( + JournalUnreleasedSchemaError + ) expect(await digest(dbPath)).toBe(before) await expect(stat(`${dbPath}-wal`)).rejects.toThrow() diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index 2c01a653af38..a63c59248d31 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -6,7 +6,12 @@ import Database from '../../sqlite/sync-database' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' -import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { + createAgentSessionRecordTablesSql, + createJournalTablesSql, + JOURNAL_DB_OLDEST_RELEASED_VERSION, + JOURNAL_DB_SCHEMA_VERSION +} from './journal-database-schema' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { ensureQueuedMessagesTable } from './queued-message-schema' @@ -20,13 +25,50 @@ export type OpenJournalDatabase = { db: Database.Database /** A newer `user_version` was met: this build reads and never writes. */ readOnly: boolean + /** The chat records file could not be read this launch: version 4's copy of it is still owed. */ + legacyRecordImportOwed: boolean +} + +/** + * What version 4's migration copies in from the chat records file that preceded it, read before + * the open so no transaction waits on a file read. `owed` is a read that can clear: the tables are + * made, and the copy and the version bump wait for a launch whose read succeeds. + */ +export type JournalLegacyRecordImport = + | { owed: true } + | { owed: false; write: (db: Database.Database) => void } + +export const NO_LEGACY_JOURNAL_RECORDS: JournalLegacyRecordImport = { + owed: false, + write: () => undefined } export function journalPragmaNumber(db: Database.Database, name: string): number { return Number(db.pragma(name, { simple: true }) ?? 0) } -export function openJournalDatabase(dbPath: string): OpenJournalDatabase { +/** Whether an open of a database at `stored` runs version 4's migration, and so reads the records + * file first. */ +export function journalDatabaseMigratesRecords(stored: number): boolean { + return ( + stored === 0 || + (stored >= JOURNAL_DB_OLDEST_RELEASED_VERSION && stored < JOURNAL_DB_SCHEMA_VERSION) + ) +} + +export function readJournalDatabaseVersion(dbPath: string): number { + const probe = new Database(dbPath) + try { + return journalPragmaNumber(probe, 'user_version') + } finally { + probe.close() + } +} + +export function openJournalDatabase( + dbPath: string, + legacyRecords: JournalLegacyRecordImport +): OpenJournalDatabase { const probe = new Database(dbPath) let stored: number try { @@ -37,25 +79,29 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { } if (stored > JOURNAL_DB_SCHEMA_VERSION) { probe.close() - return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), readOnly: true } + return { + db: new Database(dbPath, { readonly: true, fileMustExist: true }), + readOnly: true, + legacyRecordImportOwed: false + } } let transferred = false try { - if (stored !== 0 && stored < JOURNAL_DB_SCHEMA_VERSION) { + if (stored !== 0 && stored < JOURNAL_DB_OLDEST_RELEASED_VERSION) { // No retry reads past it, so every chat says it can't load; the log says what to do. throw new JournalUnreleasedSchemaError( - `chat journal ${dbPath} uses unreleased schema ${stored}, written by an unreleased development build of Orca; move the file aside and Orca starts a new one` + `chat journal ${dbPath} uses unreleased schema ${stored}, written by an unreleased development build of Orca. Orca can't load its chats. Moving the file aside lets Orca start a new one, but that loses every chat's history, tabs and ownership records stored in it.` ) } configureJournalPragmas(probe, stored) - createJournalSchema(probe, stored) - // Outside `createJournalSchema` on purpose: its early return skips a db + const legacyRecordImportOwed = migrateJournalSchema(probe, stored, legacyRecords) + // Outside `migrateJournalSchema` on purpose: its early return skips a db // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true - return { db: probe, readOnly: false } + return { db: probe, readOnly: false, legacyRecordImportOwed } } finally { if (!transferred) { probe.close() @@ -82,17 +128,35 @@ function configureJournalPragmas(db: Database.Database, stored: number): void { } /** - * Table creation and the `user_version` bump are ONE transaction. Creating the tables first left - * a shaped database still reporting version 0, which an older build does not latch read-only. + * Table creation, the records copy and the `user_version` bump are ONE transaction. Creating the + * tables first left a shaped database still reporting version 0, which an older build does not + * latch read-only; and "copied" is `user_version >= 4`, so no other marker can disagree with it. + * Returns whether the copy is still owed. */ -function createJournalSchema(db: Database.Database, stored: number): void { +function migrateJournalSchema( + db: Database.Database, + stored: number, + legacyRecords: JournalLegacyRecordImport +): boolean { if (stored >= JOURNAL_DB_SCHEMA_VERSION) { - return + return false } runJournalTransaction(db, () => { - db.exec(createJournalTablesSql()) + if (stored === 0) { + db.exec(createJournalTablesSql()) + } + db.exec(createAgentSessionRecordTablesSql()) + if (legacyRecords.owed) { + // A fresh file still takes a released version, so an older build opens it as one. + if (stored === 0) { + db.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) + } + return + } + legacyRecords.write(db) db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`) }) + return legacyRecords.owed } /** diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index 9ce0e0c34319..dbe15b4709ed 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -3,6 +3,7 @@ // same directory reads the same database the way a restarted host would. import { resolve } from 'node:path' +import { NO_LEGACY_JOURNAL_RECORDS } from './journal-database' import { JournalHostDatabase } from './journal-host-database' import { replayJournal, type JournalLoad } from './journal-open' import { serializeJournalRow, type JournalRow } from './journal-row-schema' @@ -25,11 +26,18 @@ export function openTestJournalHostDatabase(stateDirectory: string): JournalHost if (existing && !existing.isClosed) { return existing } - const database = JournalHostDatabase.open(directory) + const database = JournalHostDatabase.openWith(directory, NO_LEGACY_JOURNAL_RECORDS) opened.set(directory, database) return database } +/** Closes this directory's database, so the next open reads the file as a fresh process would. */ +export function closeTestJournalHostDatabase(stateDirectory: string): void { + const directory = resolve(stateDirectory) + opened.get(directory)?.close() + opened.delete(directory) +} + /** Closes every database this process opened for tests. */ export function closeTestJournalHostDatabases(): void { for (const database of opened.values()) { diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index b6999e4ef559..ca6036d3a9d0 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -10,8 +10,12 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session- import type Database from '../../sqlite/sync-database' import { JOURNAL_SYNCHRONOUS, + journalDatabaseMigratesRecords, + NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase, + readJournalDatabaseVersion, runJournalTransaction, + type JournalLegacyRecordImport, type OpenJournalDatabase } from './journal-database' import { journalOpenRefusalError } from './journal-open-failure' @@ -28,6 +32,8 @@ export class JournalHostDatabase { private connection: Database.Database | null /** A newer Orca wrote the database: every chat's history reads, and no chat writes. */ readonly readOnly: boolean + /** The chat records file could not be read this launch, so its copy waits for a later one. */ + readonly legacyRecordImportOwed: boolean /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false @@ -37,13 +43,33 @@ export class JournalHostDatabase { ) { this.connection = opened.db this.readOnly = opened.readOnly + this.legacyRecordImportOwed = opened.legacyRecordImportOwed } - static open(stateDirectory: string): JournalHostDatabase { + /** `readLegacyRecords` runs only when this open migrates to version 4, before any transaction. */ + static async open( + stateDirectory: string, + readLegacyRecords: () => Promise + ): Promise { + mkdirSync(stateDirectory, { recursive: true }) + const migrates = journalDatabaseMigratesRecords( + readJournalDatabaseVersion(journalDatabasePath(stateDirectory)) + ) + return JournalHostDatabase.openWith( + stateDirectory, + migrates ? await readLegacyRecords() : NO_LEGACY_JOURNAL_RECORDS + ) + } + + /** The same open with the records file already read; tests pass `NO_LEGACY_JOURNAL_RECORDS`. */ + static openWith( + stateDirectory: string, + legacyRecords: JournalLegacyRecordImport + ): JournalHostDatabase { mkdirSync(stateDirectory, { recursive: true }) return new JournalHostDatabase( stateDirectory, - openJournalDatabase(journalDatabasePath(stateDirectory)) + openJournalDatabase(journalDatabasePath(stateDirectory), legacyRecords) ) } diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts index 2e9e21d024f4..2fb343d2d508 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { openJournalDatabase } from './journal-database' +import { NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, @@ -28,7 +28,7 @@ afterEach(async () => { /** What the journal's own open, then a chat's replay, throws for the file as it stands. */ function openFailure(): unknown { try { - const db = openJournalDatabase(journalDatabasePath(root)).db + const db = openJournalDatabase(journalDatabasePath(root), NO_LEGACY_JOURNAL_RECORDS).db try { replayJournal(db, 'session-1') } finally { @@ -59,7 +59,7 @@ describe('classifyJournalOpenFailure', () => { it('calls a journal whose pages are damaged corrupt', async () => { const path = journalDatabasePath(root) - const opened = openJournalDatabase(path).db + const opened = openJournalDatabase(path, NO_LEGACY_JOURNAL_RECORDS).db opened.exec('PRAGMA journal_mode = DELETE') opened.close() const bytes = await readFile(path) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts index 406f105a7c75..1b1ae402533a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.ts @@ -216,6 +216,8 @@ export class StructuredAgentSessionHost { getPersistedVisibleSessionTabIndex = () => this.deps.store.getVisibleSessionTabIndex() getSessionTabId = (sessionId: string): string | null => this.deps.store.getSessionTabId(sessionId) showSessionTabs = (sessionIds: readonly string[]) => this.deps.store.showSessionTabs(sessionIds) + /** The records file could not be read this launch, so chats it holds are not listed yet. */ + legacyRecordImportOwed = (): boolean => this.deps.journalDatabase.legacyRecordImportOwed === true setSessionTabVisibility = async ( sessionId: string, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index 3fb865f50ec7..6606aeee8853 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -3,7 +3,7 @@ import { readdir } from 'node:fs/promises' import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' -import { JournalHostDatabase } from '../agent-session-journal/journal-host-database' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -32,7 +32,8 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag code: 'ERR_SQLITE_ERROR', errcode: 11 }) - vi.spyOn(JournalHostDatabase.prototype, 'transaction').mockImplementation(() => { + // The chat's history database; this harness keeps the ownership rows in a database of their own. + vi.spyOn(openTestJournalHostDatabase(root), 'transaction').mockImplementation(() => { throw damaged }) vi.spyOn(console, 'warn').mockImplementation(() => undefined) @@ -57,5 +58,6 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag }) ).rejects.toBe(damaged) expect(cancelTurn).toHaveBeenCalledTimes(1) - expect(await readdir(root, { recursive: true })).toEqual(files) + // A recovery-offer read still in flight holds its lock for a moment; nothing else may appear. + await vi.waitFor(async () => expect(await readdir(root, { recursive: true })).toEqual(files)) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 462111f14ade..57a02a97a60e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -8,7 +8,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type * as DurableFileWrite from '../../durable-file-write' +import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-record-store-test-harness' import type { @@ -31,21 +31,16 @@ const CALLER = { callerKey: 'client-1' } const publishFault = vi.hoisted(() => ({ failOnPublish: 0, publishCount: 0 })) -vi.mock('../../durable-file-write', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - renameDurable: async (tmpPath: string, finalPath: string) => { - if (finalPath.endsWith('agent-sessions.json')) { - publishFault.publishCount += 1 - } - if ( - finalPath.endsWith('agent-sessions.json') && - publishFault.publishCount === publishFault.failOnPublish - ) { + writeAgentSessionStoreRows: (...args: Parameters) => { + publishFault.publishCount += 1 + if (publishFault.publishCount === publishFault.failOnPublish) { throw new Error('simulated crash before failed-settlement publish') } - return actual.renameDurable(tmpPath, finalPath) + return actual.writeAgentSessionStoreRows(...args) } } }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 8033843cd41e..ac9f3cc78bde 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -1,16 +1,17 @@ -// The lease reconcile is bookkeeping: a lock that gives up or a store this build may not write is -// reported, and startup and every read carry on. Nothing is owed after it, because an unreconciled -// lease grants no writer and the next send reconciles every lease again before it acts. +// The lease reconcile is bookkeeping: a write that fails is reported, and startup and every read +// carry on. Nothing is owed after it, because an unreconciled lease grants no writer and the next +// send reconciles every lease again before it acts. Over records a newer Orca wrote, the reconcile +// adjudicates in memory and writes nothing. -import { cp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { cp, readdir, readFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' -import type * as FileTransactionLock from '../../file-transaction-lock' -import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import type * as AgentSessionRecordRows from '../../runtime/agent-session-record-rows' import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath -} from '../../runtime/agent-session-record-store-file' + openTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild +} from '../../runtime/agent-session-record-store-test-harness' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -30,34 +31,37 @@ import { } from './structured-agent-session-host-test-data' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -const lock = vi.hoisted(() => ({ failing: false })) +const writes = vi.hoisted(() => ({ failing: false })) -vi.mock('../../file-transaction-lock', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../../runtime/agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - withFileTransactionLock: (...args: Parameters) => - lock.failing - ? // What proper-lockfile throws once its retries give up. - Promise.reject( - Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) - ) - : actual.withFileTransactionLock(...args) + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing) { + // What SQLite throws when the disk will not take the commit. + throw Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) + } + return actual.writeAgentSessionStoreRows(...args) + } } }) +const IO_ERROR = expect.objectContaining({ message: 'disk I/O error' }) + const relaunchedRoots: string[] = [] afterEach(async () => { - lock.failing = false + writes.failing = false await Promise.all( relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) ) }) -/** A host with one chat, relaunched over a copy of its files; `rewrite` edits the copied store. */ +/** A host with one chat, relaunched over a copy of its files; `newer` marks the copied records as + * a newer Orca's. */ async function relaunch( - rewrite?: (json: Record) => void, + newer = false, probeOwner: StructuredAgentSessionHostDeps['probeOwner'] = async () => ({ outcome: 'pid-absent' }) @@ -74,13 +78,10 @@ async function relaunch( filter: (source) => !source.includes('.lock') }) const storeDirectory = join(relaunched, 'store') - if (rewrite) { - const path = agentSessionStorePath(storeDirectory) - const json = JSON.parse(await readFile(path, 'utf-8')) - rewrite(json) - await writeFile(path, JSON.stringify(json)) + if (newer) { + await seedTestAgentSessionStoreFromNewerBuild(storeDirectory) } - const store = await AgentSessionRecordStore.open({ directory: storeDirectory, hostId: 'local' }) + const store = await openTestAgentSessionRecordStore(storeDirectory) const onLeaseReconcileFailure = vi.fn() const host = new StructuredAgentSessionHost({ store, @@ -102,21 +103,21 @@ async function relaunch( it('reports a startup reconcile whose store write fails, and does not reject', async () => { const { host, store, onLeaseReconcileFailure } = await relaunch() - lock.failing = true + writes.failing = true await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) // Nothing was adjudicated, so the lease still grants no writer. expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) }) it('reconciles the chat on its next send once the store can be written again', async () => { const { host, store, onLeaseReconcileFailure } = await relaunch() - lock.failing = true + writes.failing = true await host.reconcileRestartLeases() expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - lock.failing = false + writes.failing = false const body = hostTestMessage('sent after a startup reconcile failed') await expect( @@ -133,29 +134,27 @@ it('reconciles the chat on its next send once the store can be written again', a await host.flushAllStreamedEvents() }) -it('reports a store a newer Orca wrote without writing it, and does not reject', async () => { - const { host, store, storeDirectory, onLeaseReconcileFailure } = await relaunch((json) => { - json.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 - }) +it('adjudicates records a newer Orca wrote in memory only, and writes nothing', async () => { + const { host, store, storeDirectory, onLeaseReconcileFailure } = await relaunch(true) expect(store.readOnly).toBe(true) - const path = agentSessionStorePath(storeDirectory) + const path = journalDatabasePath(storeDirectory) const bytes = await readFile(path) const files = await readdir(storeDirectory) await expect(host.reconcileRestartLeases()).resolves.toBeUndefined() - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith( - expect.objectContaining({ message: 'agent_session_legacy_required' }) - ) - expect(store.listRecords().map((record) => record.sessionId)).toEqual([SESSION]) + expect(onLeaseReconcileFailure).not.toHaveBeenCalled() + expect(store.getRecord(SESSION)?.lease).toMatchObject({ + unreconciled: false, + claimStatus: 'released' + }) expect(await readFile(path)).toEqual(bytes) expect(await readdir(storeDirectory)).toEqual(files) }) it('restores a chat for reading while the reconcile keeps failing, and reports it once', async () => { const { host, store, onLeaseReconcileFailure } = await relaunch() - lock.failing = true + writes.failing = true await host.reconcileRestartLeases() await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() @@ -163,37 +162,35 @@ it('restores a chat for reading while the reconcile keeps failing, and reports i expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(true) expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) }) -it('restores a chat for reading from a store a newer Orca wrote', async () => { - const { host, storeDirectory, onLeaseReconcileFailure } = await relaunch((json) => { - json.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION + 1 - }) - const path = agentSessionStorePath(storeDirectory) +it('restores a chat for reading from records a newer Orca wrote', async () => { + const { host, storeDirectory, onLeaseReconcileFailure } = await relaunch(true) + const path = journalDatabasePath(storeDirectory) const bytes = await readFile(path) await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(true) - expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(onLeaseReconcileFailure).not.toHaveBeenCalled() expect(await readFile(path)).toEqual(bytes) }) // A chat whose owner could not be proven gone is left recovering; resolving that is the writer's. it('restores a chat for reading when resolving its recovery cannot write the store', async () => { - const { host, store, onLeaseReconcileFailure } = await relaunch(undefined, async () => ({ + const { host, store, onLeaseReconcileFailure } = await relaunch(false, async () => ({ outcome: 'indeterminate', reason: 'probe' })) await host.reconcileRestartLeases() expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') - lock.failing = true + writes.failing = true await expect(host.restoreReadableSessions([SESSION])).resolves.toBeUndefined() expect(host.hasSession(SESSION)).toBe(true) expect(store.getRecord(SESSION)?.lease.handoffStage).toBe('recovering') expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() - expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) + expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) }) diff --git a/src/main/runtime/agent-session-backup-recovery.test.ts b/src/main/runtime/agent-session-backup-recovery.test.ts deleted file mode 100644 index 3c6ac2dcecf8..000000000000 --- a/src/main/runtime/agent-session-backup-recovery.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { evaluateAgentSessionAcquisition } from '../../shared/agent-session-lease-adjudication' -import { isPersistedAgentSessionRecord } from '../../shared/agent-session-record' -import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { - agentSessionStorePath, - loadAgentSessionStore, - saveAgentSessionStore -} from './agent-session-record-store-file' - -/** Reserve, observe the spawn, prove the handle: the only path to a `live` lease, whose invariant - * is that the head handle was minted at exactly the current fence. One store instance throughout, - * because reopening marks every lease unreconciled and refuses the next two steps. */ -async function seedLiveSession(sessionId: string): Promise { - const store = await openStore() - const reserved = await store.reserveOwner({ - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - expectedFence: null, - spawnToken: 'seed-live', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed-live' }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId, - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: NOW, - spawnToken: 'seed-live' - }, - now: NOW - }) - await store.proveOwner({ - sessionId, - fence, - link: { - linkId: 'link-live-1', - origin: 'created', - mintedAtFence: fence, - observedAt: NOW, - handle: { provider: 'codex', threadId: `thread-${sessionId}` } - }, - now: NOW - }) - return fence -} - -let root: string -let storePath: string - -const NOW = 1_800_000_000_000 - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-')) - storePath = agentSessionStorePath(root) - operations = 0 -}) - -afterEach(async () => { - await rm(root, { recursive: true, force: true }) -}) - -async function openStore(): Promise { - return AgentSessionRecordStore.open({ directory: root, hostId: 'local' }) -} - -let operations = 0 - -function operationId(): string { - operations += 1 - return `${NOW}-${operations.toString(16).padStart(32, '0')}` -} - -async function seedSession(sessionId: string): Promise { - const store = await openStore() - const reserved = await store.reserveOwner({ - sessionId, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: join(root, 'codex-home') }, - expectedFence: null, - spawnToken: 'seed', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'seed' }, - now: NOW - }) - return reserved.record.lease.runtimeFence -} - -describe('crash-safe store writes', () => { - it('never leaves the live path absent, and keeps a whole backup', async () => { - await seedSession('session-a') - const afterFirst = await readFile(storePath, 'utf-8') - expect(JSON.parse(afterFirst)).toBeTruthy() - - await seedSession('session-b') - // Both candidates parse: the previous generation was COPIED aside, not moved. - expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() - expect(JSON.parse(await readFile(`${storePath}.bak`, 'utf-8'))).toBeTruthy() - }) - - it('leaves no orphaned temp file behind', async () => { - await seedSession('session-a') - await seedSession('session-b') - const { readdir } = await import('node:fs/promises') - expect((await readdir(root)).filter((name) => name.endsWith('.tmp'))).toEqual([]) - }) - - it('aborts the save rather than advancing the primary past a stale backup', async () => { - await seedSession('session-a') - const committed = await readFile(storePath, 'utf-8') - const loaded = await loadAgentSessionStore(storePath, 'local') - // A directory in the backup's place makes the rotation fail the way a full or read-only - // disk would. The save must not publish a primary the backup can no longer match. - await rm(`${storePath}.bak`, { force: true }) - const { mkdir } = await import('node:fs/promises') - await mkdir(`${storePath}.bak`) - - await expect( - saveAgentSessionStore(storePath, loaded.state, { - primaryStatus: 'validated' - }) - ).rejects.toBeTruthy() - expect(await readFile(storePath, 'utf-8')).toBe(committed) - expect((await stat(`${storePath}.bak`)).isDirectory()).toBe(true) - }) -}) - -describe('recovery from the committed backup', () => { - it('completes the next transaction instead of refusing forever', async () => { - await seedSession('session-a') - await seedSession('session-b') - // The exact shape a real profile wedged in: backup only, no live file. - await rm(storePath, { force: true }) - - await expect(seedSession('session-c')).resolves.toBeGreaterThan(0) - expect(JSON.parse(await readFile(storePath, 'utf-8'))).toBeTruthy() - }) - - it('never grants the fence the lost commit could already have handed out', async () => { - const fence = await seedSession('session-a') - // A second commit is what produces the backup; the first write has nothing to rotate. - await seedSession('session-b') - await rm(storePath, { force: true }) - - const store = await openStore() - // The floor lands in the first transaction after recovery, not at load. - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const record = store.getRecord('session-a') - expect(record).toBeTruthy() - - // The lost commit could have granted fence + 1. Adjudication must skip it, or two writers - // end up holding the same number under strict-equality comparison. - const granted = evaluateAgentSessionAcquisition({ - lease: { ...record!.lease, unreconciled: false, ownerProcess: null, claimStatus: 'released' }, - expectedFence: record!.lease.runtimeFence, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - expect(granted.decision).toBe('granted') - expect(granted.decision === 'granted' && granted.nextFence).toBeGreaterThan(fence + 1) - }) - - it('does not reissue a grant after two backup fallbacks and a backup rotation', async () => { - await seedSession('session-a') - await seedSession('session-b') - const loaded = await loadAgentSessionStore(storePath, 'local') - const record = loaded.state.records.get('session-a') - if (!record) { - throw new Error('seeded session missing') - } - loaded.state.records.set('session-a', { - ...record, - lease: { - ...record.lease, - runtimeFence: 7, - claimStatus: 'released', - handoffStage: null, - reservedSpawnToken: null - } - }) - // Two commits put the prepared generation in the backup, just as normal rotation would. - await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) - await saveAgentSessionStore(storePath, loaded.state, { primaryStatus: 'validated' }) - const identity = { - location: record.location, - provider: record.provider, - accountHome: record.accountHome, - runtimeKind: record.lease.runtimeKind, - claimKeyId: record.lease.claimKeyId - } - - await rm(storePath, { force: true }) - const first = await openStore() - await first.retireClaimKey(`retire-${operationId()}`, NOW) - await first.reconcileOnRestart({ - probe: async () => ({ outcome: 'reservation-unused' }), - now: NOW - }) - expect(first.getRecord('session-a')?.lease).toMatchObject({ - runtimeFence: 7, - minimumNextFence: 9, - unreconciled: false - }) - const firstGrant = await first.reserveOwner({ - ...identity, - sessionId: 'session-a', - expectedFence: 7, - spawnToken: 'first-recovery', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'first-recovery' }, - now: NOW - }) - const firstFence = firstGrant.record.lease.runtimeFence - const rotated = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( - 'session-a' - ) - expect(rotated?.lease).toMatchObject({ runtimeFence: 7, minimumNextFence: 9 }) - - // The primary's grant is lost, but its owner may still hold that exact fence. - await rm(storePath, { force: true }) - const second = await openStore() - await second.retireClaimKey(`retire-${operationId()}`, NOW) - await second.reconcileOnRestart({ - probe: async () => ({ outcome: 'reservation-unused' }), - now: NOW - }) - const secondGrant = await second.reserveOwner({ - ...identity, - sessionId: 'session-a', - expectedFence: 7, - spawnToken: 'second-recovery', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { callerKey: 'test', operationId: operationId(), fingerprint: 'second-recovery' }, - now: NOW - }) - expect(secondGrant.record.lease.runtimeFence).toBeGreaterThan(firstFence) - expect((await openStore()).getRecord('session-a')?.lease.runtimeFence).toBe( - secondGrant.record.lease.runtimeFence - ) - }) - - it.each([ - [Number.MAX_SAFE_INTEGER - 2, Number.MAX_SAFE_INTEGER], - [Number.MAX_SAFE_INTEGER - 1, null] - ])('keeps the recovered floor safe at fence %i', async (runtimeFence, expectedFloor) => { - await seedSession('session-a') - await seedSession('session-b') - const backupPath = `${storePath}.bak` - const backup = JSON.parse(await readFile(backupPath, 'utf-8')) - backup.records['session-a'].lease.runtimeFence = runtimeFence - await writeFile(backupPath, JSON.stringify(backup)) - await rm(storePath, { force: true }) - - const recovered = await openStore() - if (expectedFloor === null) { - await expect(recovered.retireClaimKey(`retire-${operationId()}`, NOW)).rejects.toThrow( - 'agent_session_fence_exhausted' - ) - await expect(stat(storePath)).rejects.toMatchObject({ code: 'ENOENT' }) - const preserved = (await loadAgentSessionStore(backupPath, 'local')).state.records.get( - 'session-a' - )?.lease - expect(preserved?.runtimeFence).toBe(runtimeFence) - expect(preserved?.minimumNextFence).toBeUndefined() - } else { - await recovered.retireClaimKey(`retire-${operationId()}`, NOW) - expect(recovered.getRecord('session-a')?.lease).toMatchObject({ - runtimeFence, - minimumNextFence: expectedFloor - }) - expect((await openStore()).getRecord('session-a')?.lease.minimumNextFence).toBe(expectedFloor) - } - }) - - it('leaves recovered records valid, so the next load does not quarantine them', async () => { - await seedLiveSession('session-a') - await seedSession('session-b') - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const record = store.getRecord('session-a') - expect(record?.lease.claimStatus).toBe('live') - // A `live` lease means a provider handle proven at exactly lease.runtimeFence. Recovery that - // rewrote the fence broke that, so the record failed validation, was quarantined on the next - // load, and dropped straight back to the same backup. - expect(isPersistedAgentSessionRecord(record)).toBe(true) - }) - - it('carries ownership evidence forward verbatim', async () => { - await seedSession('session-a') - await seedSession('session-b') - const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.get( - 'session-a' - )! - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const after = store.getRecord('session-a')! - expect(after.lease.claimStatus).toBe(before.lease.claimStatus) - expect(after.lease.ownerProcess).toEqual(before.lease.ownerProcess) - expect(after.lease.handoffStage).toBe(before.lease.handoffStage) - // "Not currently owned" is expressed by unreconciled, not by erasing the evidence. - expect(after.lease.unreconciled).toBe(true) - }) - - // Recovery restores the previous COMMITTED generation; the lost commit is lost by definition. - // What must not happen is reconciliation dropping anything the backup did hold. - it('drops nothing the committed backup held', async () => { - await seedSession('session-a') - await seedSession('session-b') - const before = (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state - await rm(storePath, { force: true }) - - const store = await openStore() - await store.retireClaimKey(`retire-${operationId()}`, NOW) - const after = (await loadAgentSessionStore(storePath, 'local')).state - expect([...after.records.keys()].sort()).toEqual([...before.records.keys()].sort()) - expect(after.operations.size).toBe(before.operations.size) - // Everything the backup held is still there; the transaction that drove recovery adds its own. - for (const key of before.retiredClaimKeys) { - expect(after.retiredClaimKeys).toContainEqual(key) - } - expect([...after.unreadableRecords.keys()]).toEqual([...before.unreadableRecords.keys()]) - }) -}) - -describe('a transient primary read failure is not recovery', () => { - it('refuses rather than falling back to a usable but older backup', async () => { - await seedSession('session-a') - // The second commit leaves a VALID backup, so a fallback would silently succeed with - // older state — the failure this guard exists to prevent. - await seedSession('session-b') - expect( - (await loadAgentSessionStore(`${storePath}.bak`, 'local')).state.records.has('session-a') - ).toBe(true) - - // A directory at the primary path fails the read with EISDIR, not ENOENT — the shape of a - // permission or IO fault. It says nothing about the primary's contents. - await rm(storePath, { force: true }) - const { mkdir } = await import('node:fs/promises') - await mkdir(storePath) - - await expect(loadAgentSessionStore(storePath, 'local')).rejects.toThrow( - 'agent_session_store_corrupt' - ) - }) -}) - -describe('the fence-step bound the +2 floor rests on', () => { - it('advances a session fence by exactly one per grant when no floor is set', () => { - const granted = evaluateAgentSessionAcquisition({ - lease: agentSessionLeaseFixture({ - runtimeFence: 7, - claimStatus: 'released', - ownerProcess: null, - provenHandleLinkId: null - }), - expectedFence: 7, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - // If a grant could ever advance by more than one, the +2 recovery floor would stop dominating - // the highest fence a single lost commit can have handed out. - expect(granted.decision === 'granted' && granted.nextFence).toBe(8) - }) - - it('honours a recovery floor that sits above the next step', () => { - const granted = evaluateAgentSessionAcquisition({ - lease: agentSessionLeaseFixture({ - runtimeFence: 7, - minimumNextFence: 9, - claimStatus: 'released', - ownerProcess: null, - provenHandleLinkId: null - }), - expectedFence: 7, - handoffOperationId: null, - probe: { outcome: 'reservation-unused' } - }) - expect(granted.decision === 'granted' && granted.nextFence).toBe(9) - }) -}) - -describe('a store that never existed', () => { - it('does not claim recovery', async () => { - await writeFile(join(root, 'unrelated.txt'), 'x', 'utf-8') - const loaded = await loadAgentSessionStore(storePath, 'local') - expect(loaded.storeFound).toBe(false) - expect(loaded.recoveredFromBackup).toBe(false) - }) -}) diff --git a/src/main/runtime/agent-session-lease-transitions.ts b/src/main/runtime/agent-session-lease-transitions.ts index 41e8d300e1e9..0b5b6e5f2efd 100644 --- a/src/main/runtime/agent-session-lease-transitions.ts +++ b/src/main/runtime/agent-session-lease-transitions.ts @@ -19,7 +19,6 @@ import { import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' import type { AgentSessionDeathEvidence, - AgentSessionJournalCheckpoint, AgentSessionLease, AgentSessionProcessIdentity, AgentSessionRecord @@ -270,26 +269,3 @@ function releasedAgentSessionLease( deathEvidence }) } - -export function setAgentSessionJournalCheckpoint(args: { - record: AgentSessionRecord - fence: number - checkpoint: AgentSessionJournalCheckpoint - now: number -}): AgentSessionRecord { - const { record } = args - assertFence(record.lease, args.fence) - const current = record.lease.journalCheckpoint - if ( - current && - (current.epoch > args.checkpoint.epoch || - (current.epoch === args.checkpoint.epoch && current.sequence > args.checkpoint.sequence)) - ) { - throw new Error('agent_session_checkpoint_stale') - } - return withLease(record, { - ...record.lease, - journalCheckpoint: args.checkpoint, - lastRenewedAt: args.now - }) -} diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts new file mode 100644 index 000000000000..389df68bc199 --- /dev/null +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -0,0 +1,161 @@ +// The one-time copy of `agent-sessions.json` into the chat journal database, inside the version-4 +// migration (journal-database.ts). The file and its `.bak` are only ever read: an older build that +// still uses them finds them as it left them. +// +// Never blocks the host. A file no read will make usable is reported, nothing is copied, and the +// migration completes; the file stays for manual recovery. A read that can clear leaves the copy +// owed, and every later launch retries it until one reads the file, finds it absent or unusable, or +// the import is retired. + +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + NO_LEGACY_JOURNAL_RECORDS, + type JournalLegacyRecordImport +} from '../native-chat/agent-session-journal/journal-database' +import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' +import { + agentSessionStorePath, + 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 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. `directory` is where the records file lived, beside the journal database. */ +export async function readLegacyAgentSessionRecords( + directory: string, + hostId: string +): Promise { + try { + const loaded = await loadAgentSessionStore(agentSessionStorePath(directory), hostId) + return loaded.storeFound + ? { kind: 'loaded', state: loaded.state, fromBackup: loaded.recoveredFromBackup } + : { kind: 'absent' } + } catch (error) { + return readFailedTransiently(error) + ? { kind: 'unavailable', error } + : { kind: 'unusable', error } + } +} + +function safeFence(value: unknown): number | null { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null +} + +/** + * A readable record whose id also has a set-aside copy: an older build recovered the readable one + * from its backup and could not read the newer copy, which may already have granted fences the + * readable one cannot show. The next grant must clear both. + */ +function withFloorAboveSetAsideCopy(record: AgentSessionRecord, raw: unknown): AgentSessionRecord { + let floor = Math.max(record.lease.minimumNextFence ?? 0, nextAgentSessionFence(record.lease) + 1) + const rawLease = typeof raw === 'object' && raw !== null && 'lease' in raw ? raw.lease : undefined + if (typeof rawLease === 'object' && rawLease !== null && 'runtimeFence' in rawLease) { + const runtimeFence = safeFence(rawLease.runtimeFence) + const minimumNextFence = + 'minimumNextFence' in rawLease && rawLease.minimumNextFence !== undefined + ? safeFence(rawLease.minimumNextFence) + : undefined + if (runtimeFence !== null && minimumNextFence !== null) { + floor = Math.max(floor, nextAgentSessionFence({ runtimeFence, minimumNextFence })) + } + } + if (!Number.isSafeInteger(floor)) { + throw new Error('agent_session_fence_exhausted') + } + return { ...record, lease: { ...record.lease, minimumNextFence: floor } } +} + +function importRows( + state: AgentSessionStoreState, + fromBackup: boolean +): AgentSessionStoreImportRows { + const records = new Map(state.records) + if (fromBackup) { + // The commit the backup lost may have granted a fence it cannot show: main's rule for this state. + raiseAgentSessionFencesAfterBackupRecovery({ ...state, records }) + } + const rows: AgentSessionStoreImportRows['records'] = [] + for (const [sessionId, loaded] of records) { + const setAside = state.unreadableRecords.get(sessionId) + const record = setAside ? withFloorAboveSetAsideCopy(loaded, setAside.raw) : loaded + const json = JSON.stringify({ ...record, lease: withoutRetiredLeaseLatches(record.lease) }) + // A record the load rules refuse is kept as its bytes, which every load then sets aside. + rows.push([ + sessionId, + isReadableAgentSessionStoreRecord(sessionId, JSON.parse(json)) ? json : JSON.stringify(loaded) + ]) + } + for (const [sessionId, { raw }] of state.unreadableRecords) { + if (!records.has(sessionId)) { + // Verbatim: a build that can read it gets it back, and this one derives it as unreadable. + rows.push([sessionId, JSON.stringify(raw ?? null)]) + } + } + return { + records: rows, + operations: [...state.operations].map(([key, row]) => [key, JSON.stringify(row)]), + retiredClaimKeys: state.retiredClaimKeys, + sessionTabs: + state.sessionTabs?.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })) ?? null + } +} + +/** What the migration runs for `legacy`, each failure reported once here. */ +export function legacyAgentSessionRecordImport( + legacy: LegacyAgentSessionRecords, + hostId: string, + report: (report: LegacyAgentSessionRecordImportReport) => void +): JournalLegacyRecordImport { + switch (legacy.kind) { + case 'absent': + return NO_LEGACY_JOURNAL_RECORDS + case 'unavailable': + report(legacy) + return { owed: true } + case 'unusable': + report(legacy) + return NO_LEGACY_JOURNAL_RECORDS + case 'loaded': + break + } + let rows: AgentSessionStoreImportRows + try { + rows = importRows(legacy.state, legacy.fromBackup) + } catch (error) { + report({ kind: 'unusable', error }) + return NO_LEGACY_JOURNAL_RECORDS + } + if (legacy.state.hostId !== hostId) { + // Owners it names answer `unresolved` against this host, the conservative verdict. + report({ kind: 'host-mismatch', fileHostId: legacy.state.hostId, hostId }) + } + return { owed: false, write: (db) => insertAgentSessionStoreRowsIfAbsent(db, rows) } +} diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts new file mode 100644 index 000000000000..d298f93d1d53 --- /dev/null +++ b/src/main/runtime/agent-session-record-rows.ts @@ -0,0 +1,270 @@ +// The agent-session store's rows in the host's chat journal database: every row loaded once at open, +// exactly the rows a transaction changed written back, and the one-time copy of the records file. +// +// A record row this build cannot read is derived as unreadable at each load and never rewritten: a +// write only touches changed rows, and every mutation of an unreadable id is refused. + +import { + AGENT_SESSION_RECORD_SCHEMA_VERSION, + isPersistedAgentSessionRecord, + type AgentSessionLease +} from '../../shared/agent-session-record' +import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' +import type Database from '../sqlite/sync-database' +import type { SqliteRow } from '../sqlite/sqlite-statement' +import { + AGENT_SESSION_STORE_SCHEMA_VERSION, + type AgentSessionStoreState +} from './agent-session-record-store-file' +import type { AgentSessionStoreRowWrites } from './agent-session-store-draft' +import { + isReadableAgentSessionStoreOperation, + isReadableAgentSessionStoreRecord, + isReadableAgentSessionStoreTab, + isReadableRetiredAgentSessionClaimKey +} from './agent-session-store-row-rules' +import { AgentSessionTabTable, type PersistedAgentSessionTab } from './agent-session-tab-table' + +/** "Never recorded" and "recorded, now empty" differ, and #17439's legacy fallback needs the first; + * no row count can say which. */ +const SESSION_TABS_RECORDED = 'session_tabs_recorded' + +/** Latch fields older builds wrote. Nothing reads them, and dropping them keeps a lease this build + * writes back from carrying a stale latch to an older build after a downgrade. */ +type RetiredAgentSessionLeaseFields = { + processlessAt?: unknown + settlementRetryRequired?: unknown + settlementRetryId?: unknown +} + +export function withoutRetiredLeaseLatches(lease: AgentSessionLease): AgentSessionLease { + const { + processlessAt: _processlessAt, + settlementRetryRequired: _settlementRetryRequired, + settlementRetryId: _settlementRetryId, + ...current + }: AgentSessionLease & RetiredAgentSessionLeaseFields = lease + return current +} + +function text(row: SqliteRow, column: string): string | null { + const value = row[column] + return typeof value === 'string' ? value : null +} + +function parseJson(json: string | null): { ok: true; value: unknown } | { ok: false } { + if (json === null) { + return { ok: false } + } + try { + return { ok: true, value: JSON.parse(json) } + } catch { + return { ok: false } + } +} + +function unreadableRecordReason(value: unknown): string { + if (isPersistedAgentSessionRecord(value)) { + return 'record_key_session_id_mismatch' + } + const schemaVersion = + typeof value === 'object' && value !== null && 'schemaVersion' in value + ? value.schemaVersion + : undefined + return schemaVersion === AGENT_SESSION_RECORD_SCHEMA_VERSION + ? 'current_shape_invalid' + : 'unsupported_schema' +} + +/** + * The whole store, read once. Every lease loads unreconciled: the process that wrote it may still + * be alive, so nothing persisted grants a writer until this host adjudicates it. Operation, key and + * tab rows this build cannot read are skipped, as a record row it cannot read is set aside. + */ +export function loadAgentSessionStoreRows( + db: Database.Database, + hostId: string +): AgentSessionStoreState { + const state: AgentSessionStoreState = { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId, + records: new Map(), + operations: new Map(), + retiredClaimKeys: [], + unreadableRecords: new Map(), + sessionTabs: null + } + // Only a read-only database a newer build wrote can lack them; its chats then list as none here. + if ( + !db + .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('agent_session_records') + ) { + return state + } + for (const row of db + .prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY rowid') + .all()) { + const sessionId = text(row, 'session_id') + if (sessionId === null) { + continue + } + const parsed = parseJson(text(row, 'record_json')) + const value = parsed.ok ? parsed.value : text(row, 'record_json') + if (parsed.ok && isReadableAgentSessionStoreRecord(sessionId, value)) { + const { record } = normalizeLegacyHandoffRecord(value) + state.records.set(sessionId, { + ...record, + lease: { ...withoutRetiredLeaseLatches(record.lease), unreconciled: true } + }) + } else { + state.unreadableRecords.set(sessionId, { reason: unreadableRecordReason(value), raw: value }) + } + } + for (const row of db + .prepare('SELECT operation_key, row_json FROM agent_session_operations ORDER BY rowid') + .all()) { + const key = text(row, 'operation_key') + const parsed = parseJson(text(row, 'row_json')) + if (key !== null && parsed.ok && isReadableAgentSessionStoreOperation(key, parsed.value)) { + state.operations.set(key, parsed.value) + } + } + for (const row of db + .prepare( + 'SELECT key_id AS keyId, retired_at AS retiredAt FROM agent_session_retired_claim_keys ORDER BY rowid' + ) + .all()) { + const entry = { keyId: row.keyId, retiredAt: Number(row.retiredAt) } + if (isReadableRetiredAgentSessionClaimKey(entry)) { + state.retiredClaimKeys.push(entry) + } + } + const recorded = db + .prepare('SELECT 1 AS present FROM agent_session_store_meta WHERE key = ?') + .get(SESSION_TABS_RECORDED) + if (recorded) { + const table = new AgentSessionTabTable() + for (const row of db + .prepare( + 'SELECT tab_id AS tabId, session_id AS sessionId FROM agent_session_tabs ORDER BY position' + ) + .all()) { + const entry = { tabId: row.tabId, sessionId: row.sessionId } + if ( + isReadableAgentSessionStoreTab(entry) && + table.sessionIdFor(entry.tabId) === undefined && + table.tabIdFor(entry.sessionId) === undefined + ) { + table.show(entry.sessionId, entry.tabId) + } + } + state.sessionTabs = table + } + return state +} + +function writeTabIndex( + db: Database.Database, + index: { recorded: boolean; tabs: readonly PersistedAgentSessionTab[] } +): void { + db.prepare('DELETE FROM agent_session_tabs').run() + const insert = db.prepare( + 'INSERT INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)' + ) + index.tabs.forEach(({ tabId, sessionId }, position) => insert.run(tabId, sessionId, position)) + if (index.recorded) { + db.prepare('INSERT OR REPLACE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( + SESSION_TABS_RECORDED, + '1' + ) + } else { + db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) + } +} + +/** Exactly the rows a transaction changed. The caller holds the journal transaction. */ +export function writeAgentSessionStoreRows( + db: Database.Database, + writes: AgentSessionStoreRowWrites +): void { + const upsertRecord = db.prepare( + 'INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?) ON CONFLICT(session_id) DO UPDATE SET record_json = excluded.record_json' + ) + for (const [sessionId, json] of writes.records.upsert) { + upsertRecord.run(sessionId, json) + } + for (const sessionId of writes.records.remove) { + db.prepare('DELETE FROM agent_session_records WHERE session_id = ?').run(sessionId) + } + const upsertOperation = db.prepare( + 'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?) ON CONFLICT(operation_key) DO UPDATE SET row_json = excluded.row_json' + ) + for (const [key, json] of writes.operations.upsert) { + upsertOperation.run(key, json) + } + for (const key of writes.operations.remove) { + db.prepare('DELETE FROM agent_session_operations WHERE operation_key = ?').run(key) + } + if (writes.retiredClaimKeys) { + db.prepare('DELETE FROM agent_session_retired_claim_keys').run() + const insert = db.prepare( + 'INSERT INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ) + for (const { keyId, retiredAt } of writes.retiredClaimKeys) { + insert.run(keyId, retiredAt) + } + } + if (writes.sessionTabs) { + writeTabIndex(db, writes.sessionTabs) + } +} + +/** What the one-time import copies in, each row already serialized. */ +export type AgentSessionStoreImportRows = { + records: [sessionId: string, json: string][] + operations: [key: string, json: string][] + retiredClaimKeys: { keyId: string; retiredAt: number }[] + /** Null when the file never recorded a tab index. */ + sessionTabs: PersistedAgentSessionTab[] | null +} + +/** Inserts each row whose key the database does not hold yet: a chat created while the import was + * owed keeps its own rows, and ids never collide with the file's. */ +export function insertAgentSessionStoreRowsIfAbsent( + db: Database.Database, + rows: AgentSessionStoreImportRows +): void { + const insertRecord = db.prepare( + 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ) + for (const [sessionId, json] of rows.records) { + insertRecord.run(sessionId, json) + } + const insertOperation = db.prepare( + 'INSERT OR IGNORE INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)' + ) + for (const [key, json] of rows.operations) { + insertOperation.run(key, json) + } + const insertKey = db.prepare( + 'INSERT OR IGNORE INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ) + for (const { keyId, retiredAt } of rows.retiredClaimKeys) { + insertKey.run(keyId, retiredAt) + } + const tabs = rows.sessionTabs + if (tabs) { + const insertTab = db.prepare( + 'INSERT OR IGNORE INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)' + ) + // Ahead of any tab a chat created while the import was owed recorded: these were open first. + tabs.forEach(({ tabId, sessionId }, position) => + insertTab.run(tabId, sessionId, position - tabs.length) + ) + db.prepare('INSERT OR IGNORE INTO agent_session_store_meta (key, value) VALUES (?, ?)').run( + SESSION_TABS_RECORDED, + '1' + ) + } +} diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index ef0f4b1ab7f5..76eaf7f54b0c 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -1,13 +1,9 @@ /** - * On-disk layer for the durable agent-session store. - * - * Every mutation is a whole-file atomic transaction — temp write, fsync, rename — so a SIGKILL - * at any point leaves either the previous committed state or the next one, never a torn lease. - * That matters because this host restarts its runtime often; a half-written lease would be - * indistinguishable from an owner whose identity cannot be verified. + * The records file the agent-session store kept before it moved into the chat journal database, + * read once by the version-4 migration (agent-session-legacy-record-import.ts) and never written. + * Its `.bak` fallback and salvage survive here for that one read, until the import is retired. */ -import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { join } from 'node:path' import { @@ -21,10 +17,7 @@ import { type AgentSessionRecord } from '../../shared/agent-session-record' import { normalizeLegacyHandoffRecord } from '../../shared/agent-session-legacy-handoff-lease' -import { agentSessionStoreBackupPath as backupPath } from './agent-session-record-store-write' -export { saveAgentSessionStore } from './agent-session-record-store-write' import { parseAgentSessionTabTable, type AgentSessionTabTable } from './agent-session-tab-table' -import { serializeAgentSessionStoreState } from './agent-session-store-serialization' export const AGENT_SESSION_STORE_SCHEMA_VERSION = 2 as const @@ -51,16 +44,14 @@ export type LoadedAgentSessionStore = { readOnly: boolean /** True when the primary file was unusable and the previous committed copy was used. */ recoveredFromBackup: boolean - /** True when the normalized current-schema quarantine must be persisted. */ - needsRewrite: boolean - /** True when decode mapped a lease value only the removed terminal handoff wrote. */ - legacyHandoffLeasesNormalized: boolean } export function agentSessionStorePath(directory: string): string { return join(directory, AGENT_SESSION_STORE_FILE_NAME) } +const backupPath = (filePath: string): string => `${filePath}.bak` + function emptyState(hostId: string): AgentSessionStoreState { return { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, @@ -73,21 +64,7 @@ function emptyState(hostId: string): AgentSessionStoreState { } } -export function agentSessionStoreRevision(state: AgentSessionStoreState): string { - return createHash('sha256') - .update(String(state.schemaVersion)) - .update('\0') - .update(serializeAgentSessionStoreState(state)) - .digest('hex') -} - -function parseState( - raw: string, - hostId: string -): Pick< - LoadedAgentSessionStore, - 'state' | 'needsRewrite' | 'legacyHandoffLeasesNormalized' -> | null { +function parseState(raw: string, hostId: string): Pick | null { let parsed: unknown try { parsed = JSON.parse(raw) @@ -140,8 +117,6 @@ function parseState( const state = emptyState(hostId) state.schemaVersion = schemaVersion state.hostId = file.hostId - let needsRewrite = false - let legacyHandoffLeasesNormalized = false if (typeof file.records === 'object' && file.records !== null) { for (const [sessionId, value] of Object.entries(file.records)) { const decoded = isPersistedAgentSessionRecord(value) @@ -150,8 +125,6 @@ function parseState( const record = decoded?.record ?? null if (record?.sessionId === sessionId) { state.records.set(sessionId, record) - // Why: mapped while parsing, so every revision is taken over the same normalized state. - legacyHandoffLeasesNormalized ||= decoded?.normalized === true } else { const valueSchemaVersion = typeof value === 'object' && @@ -163,7 +136,6 @@ function parseState( ? 'current_shape_invalid' : 'unsupported_schema' state.unreadableRecords.set(sessionId, { reason, raw: value }) - needsRewrite ||= schemaVersion === AGENT_SESSION_STORE_SCHEMA_VERSION } } } @@ -229,7 +201,7 @@ function parseState( return null } state.sessionTabs = sessionTabs.table - return { state, needsRewrite, legacyHandoffLeasesNormalized } + return { state } } /** A record the primary retained as unreadable may still have a valid copy in the previous @@ -282,7 +254,7 @@ export async function loadAgentSessionStore( // (EACCES, EIO, EMFILE) says nothing about the primary's contents, and treating it as // recovery would replace newer state with a stale backup and latch the recovery path. if (!recoveredFromBackup) { - throw new Error('agent_session_store_corrupt') + throw new Error('agent_session_store_corrupt', { cause: error }) } unusableStoreFound = true } @@ -310,8 +282,6 @@ export async function loadAgentSessionStore( state: emptyState(hostId), storeFound: false, readOnly: false, - recoveredFromBackup: false, - needsRewrite: false, - legacyHandoffLeasesNormalized: false + recoveredFromBackup: false } } diff --git a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts b/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts deleted file mode 100644 index c614cfa5515f..000000000000 --- a/src/main/runtime/agent-session-record-store-legacy-handoff-lease.test.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Decoding a lease the removed terminal handoff wrote. Separate from the store's main suite only - * because that file is at its line cap. - */ - -import { mkdtemp, readFile, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication' -import { - readPersistedLease, - writeOlderBuildLease -} from './agent-session-older-build-lease.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' - -const NOW = 1_800_000_000_000 -const SESSION = 'session-alpha' -const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } - -let directory: string - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-legacy-lease-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -const open = (): Promise => - AgentSessionRecordStore.open({ directory, hostId: 'local' }) - -async function persistLiveOwner(): Promise { - const store = await open() - const reserved = await store.reserveOwner({ - sessionId: SESSION, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - // Without one the next open backfills it, and that rewrite would mask the lease's own. - surfaceTabId: 'tab-alpha', - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'no answer' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-${'0'.repeat(32)}`, - fingerprint: 'fp' - }, - now: NOW - }) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW - 1_000, spawnToken: 'spawn-a' }, - now: NOW - }) - await store.proveOwner({ - sessionId: SESSION, - fence, - link: { - linkId: 'link-1', - origin: 'created', - mintedAtFence: fence, - observedAt: NOW, - handle: { provider: 'claude', sessionId: 'provider-session-alpha', leafUuid: null } - }, - now: NOW - }) - // Commits the visibility index, so a later hide changes nothing but a pending rewrite. - await store.setSessionTabVisibility(SESSION, false) -} - -describe('a lease the removed terminal handoff wrote', () => { - it('loads normalized instead of quarantined, and reaches disk with the first transaction', async () => { - await persistLiveOwner() - await writeOlderBuildLease(directory, SESSION, { - runtimeKind: 'tui', - handoffStage: 'old-owner-stopped', - handoffOperationId: 'op-handoff' - }) - const legacy = await readFile(agentSessionStorePath(directory), 'utf-8') - - const store = await open() - expect(store.isSessionUnreadable(SESSION)).toBe(false) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering', - handoffOperationId: 'op-handoff', - claimStatus: 'conflicted' - }) - // An open does not write: another holder of the file mid-restart would read it as a change. - expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(legacy) - - // A transaction that changes nothing still writes it. Had the open's revision been taken over - // anything but the normalized state, this would read the file as externally changed and - // reload it, dropping the pending write. - await store.setSessionTabVisibility(SESSION, false) - expect(await readPersistedLease(directory, SESSION)).toMatchObject({ - runtimeKind: 'native', - handoffStage: 'recovering', - claimStatus: 'conflicted' - }) - }) - - it('keeps the adjudicated lease after a save, so the saved file hashes to memory', async () => { - await persistLiveOwner() - await writeOlderBuildLease(directory, SESSION, { runtimeKind: 'tui' }) - const store = await open() - await store.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 1_000 }) - expect(store.getRecord(SESSION)?.lease.unreconciled).toBe(false) - - // A reload after a mismatched hash would mark every lease unadjudicated again. - await store.setSessionTabVisibility(SESSION, true) - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - unreconciled: false, - claimStatus: 'conflicted', - handoffStage: 'recovering' - }) - const settled = await readFile(agentSessionStorePath(directory), 'utf-8') - await open() - expect(await readFile(agentSessionStorePath(directory), 'utf-8')).toBe(settled) - }) -}) diff --git a/src/main/runtime/agent-session-record-store-security.test.ts b/src/main/runtime/agent-session-record-store-security.test.ts deleted file mode 100644 index 63ee368f8737..000000000000 --- a/src/main/runtime/agent-session-record-store-security.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { chmod, mkdtemp, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -let directory: string - -function reserveRequest(): AgentSessionReserveRequest { - return { - sessionId: 'session-created', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-created', - workspaceKind: 'folder' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/accounts/created' }, - expectedFence: null, - spawnToken: 'spawn-created', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-security-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('agent session record store security', () => { - it.skipIf(process.platform === 'win32')( - 'creates the directory and store owner-only', - async () => { - const nestedDirectory = join(directory, 'agent-sessions') - await chmod(directory, 0o755) - const store = await AgentSessionRecordStore.open({ - directory: nestedDirectory, - hostId: 'local' - }) - await store.reserveOwner(reserveRequest()) - - expect((await stat(nestedDirectory)).mode & 0o777).toBe(0o700) - expect((await stat(agentSessionStorePath(nestedDirectory))).mode & 0o777).toBe(0o600) - } - ) -}) diff --git a/src/main/runtime/agent-session-record-store-security.ts b/src/main/runtime/agent-session-record-store-security.ts deleted file mode 100644 index b7293f56c99e..000000000000 --- a/src/main/runtime/agent-session-record-store-security.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { chmod, mkdir } from 'node:fs/promises' -import { dirname } from 'node:path' -import { - loadAgentSessionStore, - type LoadedAgentSessionStore -} from './agent-session-record-store-file' -import { withFileTransactionLock } from '../file-transaction-lock' - -const OWNER_DIRECTORY_MODE = 0o700 -const OWNER_FILE_MODE = 0o600 - -async function chmodIfPresent(path: string, mode: number): Promise { - try { - await chmod(path, mode) - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { - throw error - } - } -} - -export async function hardenAgentSessionStorePermissions(filePath: string): Promise { - const directory = dirname(filePath) - await mkdir(directory, { recursive: true, mode: OWNER_DIRECTORY_MODE }) - await chmod(directory, OWNER_DIRECTORY_MODE) - await Promise.all([ - chmodIfPresent(filePath, OWNER_FILE_MODE), - chmodIfPresent(`${filePath}.bak`, OWNER_FILE_MODE) - ]) -} - -export async function loadProtectedAgentSessionStore( - filePath: string, - hostId: string -): Promise { - return withFileTransactionLock(filePath, async () => { - await hardenAgentSessionStorePermissions(filePath) - return loadAgentSessionStore(filePath, hostId) - }) -} diff --git a/src/main/runtime/agent-session-record-store-tab-table.test.ts b/src/main/runtime/agent-session-record-store-tab-table.test.ts index 696eb5fd1cb0..40ee8a36b86c 100644 --- a/src/main/runtime/agent-session-record-store-tab-table.test.ts +++ b/src/main/runtime/agent-session-record-store-tab-table.test.ts @@ -3,7 +3,7 @@ * Separate from the store's main suite only because that file is at its line cap. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -15,7 +15,6 @@ import { openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from './agent-session-record-store-test-harness' -import { agentSessionStorePath } from './agent-session-record-store-file' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -70,8 +69,6 @@ async function open(): Promise { describe('chat tab table', () => { const LEGACY_TAB_ID = 'structured-agent-session-session-alpha' - const filePath = () => agentSessionStorePath(directory) - const readFileJson = async () => JSON.parse(await readFile(filePath(), 'utf-8')) it('takes a reserved id only when the tab is shown, and refuses it to a second chat', async () => { const store = await open() @@ -159,123 +156,4 @@ describe('chat tab table', () => { await store.setSessionTabVisibility('session-alpha', true, 'tab-restored') expect(store.getSessionTabId('session-alpha')).toBe('tab-restored') }) - - it('seeds the table from an older store, keeping each visible chat on the id it has today', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - await first.reserveOwner( - reserveRequest({ - sessionId: 'session-beta', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-2' } - }) - ) - await first.reserveOwner( - reserveRequest({ - sessionId: 'session-gamma', - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-3' } - }) - ) - // What a build before the table wrote: the visible list, and a tab id on some records. - const raw = await readFileJson() - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha', 'session-beta'] - raw.records['session-alpha'].surfaceTabId = 'tab-alpha' - raw.records['session-beta'].surfaceTabId = 'structured-agent-session-session-beta' - const legacy = JSON.stringify(raw) - await writeFile(filePath(), legacy) - - const reopened = await open() - expect(reopened.getSessionTabId('session-alpha')).toBe('tab-alpha') - expect(reopened.getSessionTabId('session-beta')).toBe('structured-agent-session-session-beta') - expect(reopened.getSessionTabId('session-gamma')).toBeNull() - // Seeding is part of reading, so an open alone writes nothing. - expect(await readFile(filePath(), 'utf-8')).toBe(legacy) - - await reopened.setSessionTabVisibility('session-gamma', true) - const persisted = await readFileJson() - expect(persisted.sessionTabs).toEqual([ - { tabId: 'tab-alpha', sessionId: 'session-alpha' }, - { tabId: 'structured-agent-session-session-beta', sessionId: 'session-beta' }, - { tabId: 'structured-agent-session-session-gamma', sessionId: 'session-gamma' } - ]) - // Older builds restore from this list; the record field rides along untouched and unread. - expect(persisted.visibleSessionIds).toEqual(['session-alpha', 'session-beta', 'session-gamma']) - expect(persisted.records['session-alpha'].surfaceTabId).toBe('tab-alpha') - }) - - it('seeds a chat cleared before the upgrade under the id its tab opened with', async () => { - const first = await open() - for (const [index, sessionId] of ['session-alpha', 'clear-one', 'clear-two'].entries()) { - await first.reserveOwner( - reserveRequest({ - sessionId, - operation: { - callerKey: 'client-1', - operationId: operationId(), - fingerprint: `fp-chain-${index}` - } - }) - ) - } - // An older build after two clears, with the first conversation reopened from history. - const raw = await readFileJson() - const cleared = (replacementSessionId: string) => ({ - command: 'clear', - state: 'completed', - phase: 'committed', - operationId: operationId(), - callerKey: 'client-1', - replacementSessionId - }) - raw.records['session-alpha'].conversationCommand = cleared('clear-one') - raw.records['clear-one'].conversationCommand = cleared('clear-two') - raw.records['clear-two'].surfaceTabId = 'structured-agent-session-clear-two' - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha', 'clear-two'] - await writeFile(filePath(), JSON.stringify(raw)) - - const reopened = await open() - expect(reopened.getSessionTabId('clear-two')).toBe(LEGACY_TAB_ID) - const reopenedTab = reopened.getSessionTabId('session-alpha') - expect(reopenedTab).not.toBe(LEGACY_TAB_ID) - expect(reopenedTab).not.toContain(':') - expect(reopened.listVisibleSessionIds()).toEqual(['session-alpha', 'clear-two']) - // Seeding is part of reading, so it must give the same ids on every read of the same bytes. - expect((await open()).getSessionTabId('session-alpha')).toBe(reopenedTab) - }) - - it('reads the table, never the record field, once the table is on disk', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - await first.setSessionTabVisibility('session-alpha', true, 'tab-alpha') - const raw = await readFileJson() - raw.records['session-alpha'].surfaceTabId = 'tab-stale' - await writeFile(filePath(), JSON.stringify(raw)) - expect((await open()).getSessionTabId('session-alpha')).toBe('tab-alpha') - }) - - it('keeps a record whose legacy tab id is malformed, seeding it under the derived id', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const raw = await readFileJson() - delete raw.sessionTabs - raw.visibleSessionIds = ['session-alpha'] - raw.records['session-alpha'].surfaceTabId = 'agent-session:session-alpha' - await writeFile(filePath(), JSON.stringify(raw)) - const reopened = await open() - expect(reopened.isSessionUnreadable('session-alpha')).toBe(false) - expect(reopened.getSessionTabId('session-alpha')).toBe(LEGACY_TAB_ID) - }) - - it('treats a malformed table as a corrupt store rather than guessing', async () => { - const first = await open() - await first.reserveOwner(reserveRequest()) - const raw = await readFileJson() - raw.sessionTabs = [ - { tabId: 'tab-alpha', sessionId: 'session-alpha' }, - { tabId: 'tab-alpha', sessionId: 'session-beta' } - ] - await writeFile(filePath(), JSON.stringify(raw)) - await expect(open()).rejects.toThrow('agent_session_store_corrupt') - }) }) diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index d590ecd296d1..71ab0139ee6f 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -1,38 +1,109 @@ /** * The one way tests open, seed and read back the durable agent-session record store, so a change * to where or how the store persists is made here rather than in every test that uses it. + * + * The store lives in the chat journal database of a test's state directory. A test that names the + * production layout's `agent-sessions` directory reaches the database beside it, which is the one + * the runtime installs over. */ -import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname } from 'node:path' import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' +import { + closeTestJournalHostDatabase, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import type Database from '../sqlite/sync-database' import { AgentSessionRecordStore } from './agent-session-record-store' import { AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath, type RetiredAgentSessionClaimKey } from './agent-session-record-store-file' const TEST_HOST_ID = 'local' +const LEGACY_RECORD_STORE_DIR_NAME = 'agent-sessions' /** One committed state of the store, as tests seed it and read it back. */ export type PersistedTestAgentSessionStore = { schemaVersion: number hostId: string + /** Every record row as stored, including one this build cannot read. */ records: Record operations: Record retiredClaimKeys: RetiredAgentSessionClaimKey[] + /** Written as raw record rows; always empty on a read, where `records` holds every row. */ unusableRecords: Record /** Absent until the store first records a chat tab. */ sessionTabs?: { tabId: string; sessionId: string }[] } +function stateDirectoryFor(directory: string): string { + return basename(directory) === LEGACY_RECORD_STORE_DIR_NAME ? dirname(directory) : directory +} + +function databaseFor(directory: string): Database.Database { + return openTestJournalHostDatabase(stateDirectoryFor(directory)).db +} + /** Opens, or reopens, the store kept in `directory`: what a fresh app process does at launch. */ -export function openTestAgentSessionRecordStore( +export async function openTestAgentSessionRecordStore( directory: string, options: { hostId?: string } = {} ): Promise { - return AgentSessionRecordStore.open({ directory, hostId: options.hostId ?? TEST_HOST_ID }) + return AgentSessionRecordStore.open({ + journalDatabase: openTestJournalHostDatabase(stateDirectoryFor(directory)), + hostId: options.hostId ?? TEST_HOST_ID + }) +} + +function writePersisted(db: Database.Database, persisted: PersistedTestAgentSessionStore): void { + db.exec('BEGIN IMMEDIATE') + try { + for (const table of [ + 'agent_session_records', + 'agent_session_operations', + 'agent_session_retired_claim_keys', + 'agent_session_tabs', + 'agent_session_store_meta' + ]) { + db.prepare(`DELETE FROM ${table}`).run() + } + const insertRecord = db.prepare( + 'INSERT OR REPLACE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ) + for (const [sessionId, { raw }] of Object.entries(persisted.unusableRecords)) { + insertRecord.run(sessionId, JSON.stringify(raw ?? null)) + } + for (const [sessionId, record] of Object.entries(persisted.records)) { + insertRecord.run(sessionId, JSON.stringify(record)) + } + for (const [key, row] of Object.entries(persisted.operations)) { + db.prepare( + 'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?)' + ).run(key, JSON.stringify(row)) + } + for (const { keyId, retiredAt } of persisted.retiredClaimKeys) { + db.prepare( + 'INSERT INTO agent_session_retired_claim_keys (key_id, retired_at) VALUES (?, ?)' + ).run(keyId, retiredAt) + } + if (persisted.sessionTabs) { + persisted.sessionTabs.forEach(({ tabId, sessionId }, position) => + db + .prepare('INSERT INTO agent_session_tabs (tab_id, session_id, position) VALUES (?, ?, ?)') + .run(tabId, sessionId, position) + ) + db.prepare( + "INSERT INTO agent_session_store_meta (key, value) VALUES ('session_tabs_recorded', '1')" + ).run() + } + db.exec('COMMIT') + } catch (error) { + db.exec('ROLLBACK') + throw error + } } /** Leaves `records` behind as an earlier run of the app would have, before anything opens it. */ @@ -40,37 +111,84 @@ export async function seedTestAgentSessionRecordStore( directory: string, seed: { records: readonly PersistedAgentSessionRecord[] } ): Promise { - const persisted: PersistedTestAgentSessionStore = { + writePersisted(databaseFor(directory), { schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: TEST_HOST_ID, records: Object.fromEntries(seed.records.map((record) => [record.sessionId, record])), operations: {}, retiredClaimKeys: [], unusableRecords: {} - } - await mkdir(directory, { recursive: true }) - await writeFile(agentSessionStorePath(directory), JSON.stringify(persisted), 'utf-8') + }) } /** Leaves an empty store a newer build wrote: this build reads it but never writes it. */ export async function seedTestAgentSessionStoreFromNewerBuild(directory: string): Promise { - await mkdir(directory, { recursive: true }) - await writeFile( - agentSessionStorePath(directory), - JSON.stringify({ schemaVersion: 99, hostId: TEST_HOST_ID, records: {}, operations: {} }) + const stateDirectory = stateDirectoryFor(directory) + openTestJournalHostDatabase(stateDirectory).db.pragma( + `user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}` ) + closeTestJournalHostDatabase(stateDirectory) } -/** Everything the store has committed, as text: to assert a value never reached disk, or that an - * action wrote nothing by comparing two reads. */ -export function readPersistedTestAgentSessionStoreText(directory: string): Promise { - return readFile(agentSessionStorePath(directory), 'utf-8') +function parsed(json: unknown): unknown { + return typeof json === 'string' ? JSON.parse(json) : null } export async function readPersistedTestAgentSessionStore( directory: string ): Promise { - return JSON.parse(await readPersistedTestAgentSessionStoreText(directory)) + const db = databaseFor(directory) + const persisted: PersistedTestAgentSessionStore = { + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, + hostId: TEST_HOST_ID, + records: {}, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + } + for (const row of db + .prepare('SELECT session_id, record_json FROM agent_session_records ORDER BY rowid') + .all()) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test read-back of rows the store wrote; a test asserting on a malformed row reads it as the value it stored. + persisted.records[String(row.session_id)] = parsed( + row.record_json + ) as PersistedAgentSessionRecord + } + for (const row of db + .prepare('SELECT operation_key, row_json FROM agent_session_operations ORDER BY rowid') + .all()) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: test read-back of rows the store wrote after validating them. + persisted.operations[String(row.operation_key)] = parsed( + row.row_json + ) as AgentSessionOperationRow + } + for (const row of db + .prepare('SELECT key_id, retired_at FROM agent_session_retired_claim_keys ORDER BY rowid') + .all()) { + persisted.retiredClaimKeys.push({ + keyId: String(row.key_id), + retiredAt: Number(row.retired_at) + }) + } + if ( + db + .prepare( + "SELECT 1 AS present FROM agent_session_store_meta WHERE key = 'session_tabs_recorded'" + ) + .get() + ) { + persisted.sessionTabs = db + .prepare('SELECT tab_id, session_id FROM agent_session_tabs ORDER BY position') + .all() + .map((row) => ({ tabId: String(row.tab_id), sessionId: String(row.session_id) })) + } + return persisted +} + +/** Everything the store has committed, as text: to assert a value never reached disk, or that an + * action wrote nothing by comparing two reads. */ +export async function readPersistedTestAgentSessionStoreText(directory: string): Promise { + return JSON.stringify(await readPersistedTestAgentSessionStore(directory)) } /** Changes the committed state behind the store's back, as an older build or a damaged disk would. */ @@ -80,5 +198,5 @@ export async function editPersistedTestAgentSessionStore( ): Promise { const persisted = await readPersistedTestAgentSessionStore(directory) edit(persisted) - await writeFile(agentSessionStorePath(directory), JSON.stringify(persisted)) + writePersisted(databaseFor(directory), persisted) } diff --git a/src/main/runtime/agent-session-record-store-write.ts b/src/main/runtime/agent-session-record-store-write.ts deleted file mode 100644 index 76f68f03de33..000000000000 --- a/src/main/runtime/agent-session-record-store-write.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Committing the durable agent-session store. - * - * Split from the read/parse half so neither outgrows its budget. The ordering below is the whole - * reason this is its own file: the live path is never absent. New content is made durable in a temp - * file first, a validated primary is COPIED to the backup, and only then does the rename publish it. - * Backup recovery keeps the known-good backup in place while publishing the repaired primary. - * - * The old ordering renamed the live file aside before writing the new one, so a death in that - * window left the profile with a backup and no primary — which is exactly the state that wedged a - * real profile. Copy, don't move. - */ - -import { chmod, mkdir, rm } from 'node:fs/promises' -import { dirname } from 'node:path' -import { - copyFileDurable, - durableWriteTempPath, - renameDurable, - writeTempFileDurable -} from '../durable-file-write' -import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { serializeAgentSessionStoreState } from './agent-session-store-serialization' - -export const agentSessionStoreBackupPath = (filePath: string): string => `${filePath}.bak` - -export async function saveAgentSessionStore( - filePath: string, - state: AgentSessionStoreState, - options: { primaryStatus: 'validated' | 'unusable-or-absent' } -): Promise { - const directory = dirname(filePath) - await mkdir(directory, { recursive: true, mode: 0o700 }) - await chmod(directory, 0o700) - const tmpPath = durableWriteTempPath(filePath) - try { - await writeTempFileDurable(tmpPath, serializeAgentSessionStoreState(state), 0o600) - // Only a primary parsed under the transaction lock may replace the backup. During recovery the - // primary is corrupt or absent, so the known-good backup must survive until publication. - if (options.primaryStatus === 'validated') { - await copyFileDurable(filePath, agentSessionStoreBackupPath(filePath)) - } - await renameDurable(tmpPath, filePath) - } catch (error) { - await rm(tmpPath, { force: true }).catch(() => {}) - throw error - } -} diff --git a/src/main/runtime/agent-session-record-store.test.ts b/src/main/runtime/agent-session-record-store.test.ts index 7ea6174da70d..12aa70b330cd 100644 --- a/src/main/runtime/agent-session-record-store.test.ts +++ b/src/main/runtime/agent-session-record-store.test.ts @@ -1,4 +1,4 @@ -import { 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' @@ -19,10 +19,7 @@ import { seedTestAgentSessionStoreFromNewerBuild } from './agent-session-record-store-test-harness' import { AGENT_SESSION_CLAIM_KEY_RETENTION_MS } from './agent-session-claim-key-retention' -import { - agentSessionStorePath, - AGENT_SESSION_STORE_FILE_NAME -} from './agent-session-record-store-file' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' const NOW = 1_800_000_000_000 @@ -44,9 +41,6 @@ const FOLDER: AgentSessionExecutionLocation = { const MATCHED: AgentSessionOwnerProbe = { outcome: 'identity-matched', matchedOn: ['spawn-token'] } const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' } const UNUSED: AgentSessionOwnerProbe = { outcome: 'reservation-unused' } -const BAD_OP_STORE = '{"schemaVersion":0,"hostId":"","records":{},"operations":{"x":0}}' -const BAD_KEY_STORE = - '{"schemaVersion":1,"hostId":"","records":{},"operations":{},"retiredClaimKeys":[0]}' let counter = 0 @@ -291,22 +285,6 @@ describe('acquisition path', () => { }) describe('concurrent claims', () => { - it('lets only one store instance reserve a session from the same disk snapshot', async () => { - const [first, second] = await Promise.all([open(), open()]) - const results = await Promise.allSettled([ - first.reserveOwner(reserveRequest()), - second.reserveOwner(reserveRequest()) - ]) - - expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1) - const refused = results.find((result) => result.status === 'rejected') - expect((refused as PromiseRejectedResult).reason.message).toBe('agent_session_conflict') - - const persisted = await open() - expect(persisted.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) - expect(persisted.listOperationRows()).toHaveLength(1) - }) - it('lets exactly one of two concurrent reservations win and never spawns the loser', async () => { const store = await open() await establishOwner(store) @@ -381,18 +359,16 @@ describe('concurrent claims', () => { const store = await open() await establishOwner(store) const before = store.getRecord('session-alpha') - // Losing both committed copies must not reset the live store to empty authority. - await rm(directory, { recursive: true, force: true }) - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 9, sequence: 9 }, - now: NOW - }) - ).rejects.toThrow() - expect(store.getRecord('session-alpha')).toEqual(before) - expect(store.getRecord('session-alpha')?.lease.journalCheckpoint).toBeNull() + const persistedBefore = await readPersistedTestAgentSessionStore(directory) + // The row write fails inside the commit, as a full disk or an I/O error would. + openTestJournalHostDatabase(directory).db.exec( + "CREATE TRIGGER fail_record_write BEFORE UPDATE ON agent_session_records BEGIN SELECT RAISE(ABORT, 'disk I/O error'); END" + ) + await expect(store.setConversationName('session-alpha', 'renamed')).rejects.toThrow( + 'disk I/O error' + ) + expect(store.getRecord('session-alpha')).toBe(before) + expect(await readPersistedTestAgentSessionStore(directory)).toEqual(persistedBefore) }) }) @@ -506,15 +482,14 @@ describe('restart reconciliation', () => { expect(record?.lease.provenHandleLinkId).toBe('link-1') }) - it('never applies a stale restart probe to a replacement owner', async () => { - const writer = await open() - await establishOwner(writer) - const reconciler = await open() + it('never applies a stale restart probe to a lease another reconcile already settled', async () => { + await establishOwner(await open()) + const store = await open() let releaseProbe!: (probe: AgentSessionOwnerProbe) => void let markProbeStarted!: () => void const probeStarted = new Promise((resolve) => (markProbeStarted = resolve)) const probeResult = new Promise((resolve) => (releaseProbe = resolve)) - const reconciliation = reconciler.reconcileOnRestart({ + const stale = store.reconcileOnRestart({ probe: async () => { markProbeStarted() return probeResult @@ -523,45 +498,16 @@ describe('restart reconciliation', () => { }) await probeStarted - await writer.evictProvenDeadOwner({ - sessionId: 'session-alpha', - expectedFence: 1, - probe: { outcome: 'pid-absent' }, - now: NOW + 100 - }) - const replacement = await writer.reserveOwner( - reserveRequest({ - expectedFence: 2, - probe: UNUSED, - spawnToken: 'spawn-b', - operation: { - callerKey: 'client-1', - operationId: operationId(NOW + 200), - fingerprint: 'fp-2' - }, - now: NOW + 200 - }) - ) - await writer.commitProcessIdentity({ - sessionId: 'session-alpha', - fence: replacement.record.lease.runtimeFence, - process: processIdentity({ pid: 5252, spawnToken: 'spawn-b' }), - now: NOW + 300 - }) - await writer.proveOwner({ - sessionId: 'session-alpha', - fence: replacement.record.lease.runtimeFence, - link: handleLink({ linkId: 'link-2', origin: 'resumed', mintedAtFence: 3 }), - now: NOW + 300 - }) + const settled = await store.reconcileOnRestart({ probe: async () => MATCHED, now: NOW + 500 }) + expect(settled.get('session-alpha')?.lease).toMatchObject({ claimStatus: 'live' }) releaseProbe({ outcome: 'pid-absent' }) - expect(await reconciliation).toEqual(new Map()) + expect(await stale).toEqual(new Map()) const persisted = await readPersistedTestAgentSessionStore(directory) expect(persisted.records['session-alpha'].lease).toMatchObject({ - runtimeFence: 3, + runtimeFence: 1, claimStatus: 'live', - ownerProcess: { pid: 5252, spawnToken: 'spawn-b' }, + ownerProcess: { pid: 4242, spawnToken: 'spawn-a' }, unreconciled: false }) }) @@ -760,7 +706,7 @@ describe('host and workspace isolation', () => { }) }) -describe('claim keys, checkpoints, and unreadable rows', () => { +describe('claim keys and unreadable rows', () => { it('keeps a retired claim key verifiable for the retention window', async () => { const store = await open() await store.retireClaimKey('key-1', NOW) @@ -773,40 +719,6 @@ describe('claim keys, checkpoints, and unreadable rows', () => { expect(store.isClaimKeyVerifiable('key-unknown', NOW)).toBe(true) }) - it('refuses a journal checkpoint that moves backwards', async () => { - const store = await open() - await establishOwner(store) - await store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 2, sequence: 10 }, - now: NOW - }) - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 2, sequence: 9 }, - now: NOW - }) - ).rejects.toThrow('agent_session_checkpoint_stale') - await expect( - store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 999 }, - now: NOW - }) - ).rejects.toThrow('agent_session_checkpoint_stale') - const advanced = await store.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 3, sequence: 0 }, - now: NOW - }) - expect(advanced.lease.journalCheckpoint).toEqual({ epoch: 3, sequence: 0 }) - }) - it.each([ [ 'invalid checkpoint', @@ -827,54 +739,15 @@ describe('claim keys, checkpoints, and unreadable rows', () => { expect((await open()).isSessionUnreadable('session-alpha')).toBe(true) }) - it('recovers the previous committed state when the primary file is corrupt', async () => { - const first = await open() - await establishOwner(first) - // A second commit leaves the first as the backup. - await first.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 1 }, - now: NOW - }) - await writeFile(join(directory, AGENT_SESSION_STORE_FILE_NAME), '{ truncated') - - const reopened = await open() - expect(reopened.recoveredFromBackup).toBe(true) - expect(reopened.getRecord('session-alpha')?.lease.runtimeFence).toBe(1) - - // The next transaction completes. It used to reject forever: the latch that guarded against - // the lost commit's fence had no exit, so a profile in this state could never write again. - await expect(reopened.retireClaimKey('key-2', NOW)).resolves.not.toThrow() - // Safety is kept by recording a FLOOR the next grant must clear, not by rewriting the current - // fence: `live` means a handle proven at exactly that number, so moving it would invalidate the - // record. The floor dominates the highest fence the lost commit could have granted (1 + 1). - const recovered = reopened.getRecord('session-alpha') - expect(recovered?.lease.runtimeFence).toBe(1) - expect(recovered?.lease.minimumNextFence).toBe(3) - }) - - it.each([ - ['corrupt', ['{ truncated']], - ['missing required collections', ['{"schemaVersion":1,"hostId":"local"}']], - ['invalid operation row', [BAD_OP_STORE]], - ['invalid retired key', [BAD_KEY_STORE]], - ['corrupt in both committed copies', ['{ truncated', '{ also truncated']] - ])('fails closed when the store is %s', async (_name, copies) => { - const filePath = agentSessionStorePath(directory) - await writeFile(filePath, copies[0]) - if (copies[1]) { - await writeFile(`${filePath}.bak`, copies[1]) - } - await expect(open()).rejects.toThrow('agent_session_store_corrupt') - }) - - it('refuses to write a store written by a newer schema', async () => { + it('refuses to write a store a newer Orca wrote, with the refusal clients print as "update"', async () => { await seedTestAgentSessionStoreFromNewerBuild(directory) const store = await open() expect(store.readOnly).toBe(true) - await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( - 'agent_session_legacy_required' - ) + await expect(store.reserveOwner(reserveRequest())).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) }) }) diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 603b5ed02faf..3992bb3503a3 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -1,7 +1,9 @@ +/** Durable single-writer session records and their operation ledger, as rows in the host's chat + * journal database. */ + import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { commitConversationCommandRecord } from './agent-session-conversation-command-record' import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' -/** Durable single-writer session records and their operation ledger. */ import { agentSessionOperationKey, @@ -29,7 +31,6 @@ import type { AgentSessionProviderHandleLink } from '../../shared/agent-session- import { agentSessionScopeKey, type AgentSessionExecutionLocation, - type AgentSessionJournalCheckpoint, type AgentSessionOptionsReplacement, type AgentSessionRecord } from '../../shared/agent-session-record' @@ -37,7 +38,6 @@ import { commitAgentSessionProcessIdentity, evictAgentSessionOwner, proveAgentSessionOwner, - setAgentSessionJournalCheckpoint, type AgentSessionProcessIdentityCommit } from './agent-session-lease-transitions' import { @@ -61,17 +61,11 @@ import { type AgentSessionReserveRequest, type AgentSessionReserveResult } from './agent-session-reservation-admission' -import { - agentSessionStoreRevision, - agentSessionStorePath, - type AgentSessionStoreState -} from './agent-session-record-store-file' +import type { AgentSessionStoreState } from './agent-session-record-store-file' import { setAgentSessionTabVisibility, showAgentSessionTabs } from './agent-session-tab-table' -import { loadProtectedAgentSessionStore } from './agent-session-record-store-security' -import { - AgentSessionStoreTransactionQueue, - markAgentSessionStoreLeasesUnreconciled -} from './agent-session-store-transaction-queue' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { loadAgentSessionStoreRows } from './agent-session-record-rows' +import { AgentSessionStoreTransactions } from './agent-session-store-transactions' export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -79,46 +73,32 @@ export const AGENT_SESSION_LEASE_TTL_MS = 30_000, export class AgentSessionRecordStore { private readonly deathEvidenceListeners = new Set<(sessionId: string) => void>() - private constructor(private readonly transactions: AgentSessionStoreTransactionQueue) {} - - static async open(args: { directory: string; hostId: string }): Promise { - const filePath = agentSessionStorePath(args.directory) - const loaded = await loadProtectedAgentSessionStore(filePath, args.hostId) - // Why: every persisted lease is unreconciled until this host adjudicates it, so a restart - // grants no writer on the strength of what the previous process wrote. - const diskRevision = agentSessionStoreRevision(loaded.state) - // The normalized legacy leases reach disk with this store's first transaction rather than a - // write here: a rewrite at open would read as an external change to any other holder of the - // file mid-restart. - markAgentSessionStoreLeasesUnreconciled(loaded.state) - const transactions = AgentSessionStoreTransactionQueue.fromLoadedStore( - filePath, - args.hostId, - { ...loaded, needsRewrite: loaded.needsRewrite || loaded.legacyHandoffLeasesNormalized }, - diskRevision + private constructor( + private readonly transactions: AgentSessionStoreTransactions, + readonly hostId: string + ) {} + + /** Reads every row once; nothing re-reads them. `hostId` is the execution host this runtime is. */ + static open(args: { + journalDatabase: JournalHostDatabase + hostId: string + }): AgentSessionRecordStore { + const loaded = loadAgentSessionStoreRows(args.journalDatabase.db, args.hostId) + return new AgentSessionRecordStore( + new AgentSessionStoreTransactions(args.journalDatabase, loaded), + args.hostId ) - if (loaded.needsRewrite && !loaded.readOnly && !loaded.recoveredFromBackup) { - await transactions.persistLoadedRewrite() - } - return new AgentSessionRecordStore(transactions) } private get state(): AgentSessionStoreState { return this.transactions.state } + /** A newer Orca wrote the database: every read answers, and every write is refused. */ get readOnly(): boolean { return this.transactions.readOnly } - get recoveredFromBackup(): boolean { - return this.transactions.recoveredFromBackup - } - - get hostId(): string { - return this.state.hostId - } - getRecord = (sessionId: string): AgentSessionRecord | null => this.state.records.get(sessionId) ?? null @@ -143,13 +123,13 @@ export class AgentSessionRecordStore { * Showing keeps a tab the session already has; `tabId` puts a hidden one back under its old id. */ setSessionTabVisibility(sessionId: string, visible: boolean, tabId?: string): Promise { - return this.transact(() => setAgentSessionTabVisibility(this.state, sessionId, visible, tabId)) + return this.transact((draft) => setAgentSessionTabVisibility(draft, sessionId, visible, tabId)) } /** Shows each session that still has a record, in one write: an index written part way would * read as complete at the next launch and drop the rest. */ showSessionTabs(sessionIds: readonly string[]): Promise { - return this.transact(() => showAgentSessionTabs(this.state, sessionIds)) + return this.transact((draft) => showAgentSessionTabs(draft, sessionIds)) } listByScope(location: AgentSessionExecutionLocation): AgentSessionRecord[] { @@ -162,8 +142,8 @@ export class AgentSessionRecordStore { fence: number, command: NonNullable ): Promise { - return this.transact(() => - commitConversationCommandRecord(this.state, sessionId, fence, command) + return this.transact((draft) => + commitConversationCommandRecord(draft, sessionId, fence, command) ) } @@ -188,8 +168,8 @@ export class AgentSessionRecordStore { isAgentSessionClaimKeyVerifiable(this.state, keyId, now) async reserveOwner(request: AgentSessionReserveRequest): Promise { - return this.transact(() => - commitAgentSessionReservation(this.state, request, AGENT_SESSION_LEASE_TTL_MS) + return this.transact((draft) => + commitAgentSessionReservation(draft, request, AGENT_SESSION_LEASE_TTL_MS) ) } @@ -225,11 +205,11 @@ export class AgentSessionRecordStore { /** Settle the failed attach and its reservation in one durable transaction. */ settleFailedAcquisition = (args: AgentSessionFailedAcquisitionSettlement) => - this.transact(() => settleFailedAgentSessionAcquisition(this.state, args)) + this.transact((draft) => settleFailedAgentSessionAcquisition(draft, args)) settleFailedPostAcquisitionAttachment = ( args: AgentSessionFailedPostAcquisitionAttachmentSettlement - ) => this.transact(() => settleFailedAgentSessionPostAcquisitionAttachment(this.state, args)) + ) => this.transact((draft) => settleFailedAgentSessionPostAcquisitionAttachment(draft, args)) async renewLease(args: AgentSessionLeaseRenewal): Promise { const [renewed] = await this.renewLeases([args]) @@ -237,8 +217,8 @@ export class AgentSessionRecordStore { } async renewLeases(renewals: readonly AgentSessionLeaseRenewal[]): Promise { - return this.transact(() => - renewAgentSessionLeases(this.state, renewals, AGENT_SESSION_LEASE_TTL_MS) + return this.transact((draft) => + renewAgentSessionLeases(draft, renewals, AGENT_SESSION_LEASE_TTL_MS) ) } @@ -258,38 +238,33 @@ export class AgentSessionRecordStore { return this.mutate(sessionId, transition) } - async setJournalCheckpoint(args: { - sessionId: string - fence: number - checkpoint: AgentSessionJournalCheckpoint - now: number - }): Promise { - return this.mutate(args.sessionId, (record) => - setAgentSessionJournalCheckpoint({ ...args, record }) - ) - } - - /** Adjudicate every lease this host loaded. No lease grants a writer until it appears here. */ + /** + * Adjudicate every lease this host loaded. No lease grants a writer until it appears here. On a + * database a newer Orca wrote, the verdicts are kept in memory only: they are re-derived at every + * start, and none grants a writer there, since every grant is a write. + */ async reconcileOnRestart( args: AgentSessionRestartProbeArgs ): Promise> { const pending = this.listRecords().filter((record) => record.lease.unreconciled) const probes = await collectAgentSessionRestartProbes(pending, args) - return this.transact(() => applyAgentSessionRestartProbes(this.state, probes, args.now)) + return this.transact((draft) => applyAgentSessionRestartProbes(draft, probes, args.now), { + inMemoryWhenReadOnly: true + }) } /** Admits one non-reservation mutation through the durable ledger. */ admitOperation = (args: AgentSessionOperationAdmission): Promise => - this.transact(() => admitAgentSessionOperationInto(this.state, args)) + this.transact((draft) => admitAgentSessionOperationInto(draft, args)) /** Send ids stay global after a caller reconnects under a different identity. */ admitGlobalOperation = ( args: AgentSessionOperationAdmission ): Promise => - this.transact(() => admitAgentSessionGlobalOperationInto(this.state, args)) + this.transact((draft) => admitAgentSessionGlobalOperationInto(draft, args)) admitMutationOperation = (args: AgentSessionMutationOperationAdmission) => - this.transact(() => admitAgentSessionMutationOperation(this.state, args)) + this.transact((draft) => admitAgentSessionMutationOperation(draft, args)) /** The ledger's answer alone, placing nothing; `admitMutationOperation` is the transaction. */ evaluateMutationOperation = (args: AgentSessionMutationOperationAdmission) => @@ -302,36 +277,36 @@ export class AgentSessionRecordStore { callerKey: string operationId: string }): Promise => - this.transact(() => claimAgentSessionOperationInto(this.state, args)) + this.transact((draft) => claimAgentSessionOperationInto(draft, args)) async recordOperationOutcome(args: { callerKey?: string operationId: string outcome: AgentSessionOperationOutcome }): Promise { - await this.transact(() => settleAgentSessionOperationInto(this.state, args)) + await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) async retireClaimKey(keyId: string, now: number): Promise { - await this.transact(() => retireAgentSessionClaimKey(this.state, keyId, now)) + await this.transact((draft) => retireAgentSessionClaimKey(draft, keyId, now)) } private async mutate( sessionId: string, apply: (record: AgentSessionRecord) => AgentSessionRecord ): Promise { - return this.transact(() => { - const record = this.state.records.get(sessionId) + return this.transact((draft) => { + const record = draft.records.get(sessionId) if (!record) { - throw this.isSessionUnreadable(sessionId) + throw draft.unreadableRecords.has(sessionId) ? agentSessionRefusalError('execution_owner_reconciling', { reason: 'recordUnreadable' }) : agentSessionRefusalError('agent_session_identity_required', { reason: 'recordMissing' }) } const next = apply(record) - this.state.records.set(sessionId, next) + draft.records.set(sessionId, next) return next }) } @@ -343,22 +318,26 @@ export class AgentSessionRecordStore { return () => this.deathEvidenceListeners.delete(listener) } - /** Serialize every mutation against the latest committed disk state. */ - private transact = async (apply: () => T): Promise => { + /** Serializes every mutation. `apply` changes only the draft it is given; readers see the change + * once its rows have committed. */ + private transact = async ( + apply: (draft: AgentSessionStoreState) => T, + options?: { inMemoryWhenReadOnly?: boolean } + ): Promise => { let proven: string[] = [] - const result = await this.transactions.transact(() => { + const result = await this.transactions.transact((draft) => { if (this.deathEvidenceListeners.size === 0) { - return apply() + return apply(draft) } const before = new Map( - [...this.state.records].map(([id, record]) => [id, record.lease.deathEvidence]) + [...draft.records].map(([id, record]) => [id, record.lease.deathEvidence]) ) - const applied = apply() - proven = [...this.state.records] + const applied = apply(draft) + proven = [...draft.records] .filter(([id, { lease }]) => lease.deathEvidence && lease.deathEvidence !== before.get(id)) .map(([id]) => id) return applied - }) + }, options) for (const sessionId of proven) { this.deathEvidenceListeners.forEach((listener) => listener(sessionId)) } diff --git a/src/main/runtime/agent-session-record-unsupported-schema.test.ts b/src/main/runtime/agent-session-record-unsupported-schema.test.ts deleted file mode 100644 index dd0a9dcd28d7..000000000000 --- a/src/main/runtime/agent-session-record-unsupported-schema.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath -} from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -const SESSION_ID = 'session-alpha-1' -let directory: string - -function reserveRequest(): AgentSessionReserveRequest { - return { - sessionId: SESSION_ID, - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'codex', - accountHome: { variable: 'CODEX_HOME', path: '/home/user/.codex' }, - expectedFence: null, - spawnToken: 'spawn-new', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' }, - operation: { - callerKey: 'client-1', - operationId: `${NOW}-00000000000000000000000000000001`, - fingerprint: 'fp-1' - }, - now: NOW - } -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-unsupported-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('unsupported agent session record schema', () => { - it('quarantines without upgrading and keeps the session fail-closed', async () => { - const filePath = agentSessionStorePath(directory) - const unsupported = { ...agentSessionRecordFixture(), schemaVersion: 1 } - const payload = JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, - hostId: 'local', - records: { [SESSION_ID]: unsupported }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - await Promise.all([writeFile(filePath, payload), writeFile(`${filePath}.bak`, payload)]) - - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - - expect(store.getRecord(SESSION_ID)).toBeNull() - expect(store.isSessionUnreadable(SESSION_ID)).toBe(true) - await expect(store.reserveOwner(reserveRequest())).rejects.toThrow( - 'execution_owner_reconciling' - ) - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records).not.toHaveProperty(SESSION_ID) - expect(persisted.unusableRecords[SESSION_ID]).toMatchObject({ - reason: 'unsupported_schema', - raw: { schemaVersion: 1 } - }) - }) - - it('rejects an ad-hoc store schema without rewriting it', async () => { - const filePath = agentSessionStorePath(directory) - const payload = JSON.stringify({ - schemaVersion: 1, - hostId: 'local', - records: {}, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - await writeFile(filePath, payload) - - await expect(AgentSessionRecordStore.open({ directory, hostId: 'local' })).rejects.toThrow( - 'agent_session_store_corrupt' - ) - await expect(readFile(filePath, 'utf-8')).resolves.toBe(payload) - }) -}) diff --git a/src/main/runtime/agent-session-recovery-publish-fault.test.ts b/src/main/runtime/agent-session-recovery-publish-fault.test.ts deleted file mode 100644 index 9fdbe393334f..000000000000 --- a/src/main/runtime/agent-session-recovery-publish-fault.test.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type * as DurableFileWrite from '../durable-file-write' -import { - agentSessionStoreRevision, - loadAgentSessionStore, - saveAgentSessionStore, - type AgentSessionStoreState -} from './agent-session-record-store-file' -import { AgentSessionStoreTransactionQueue } from './agent-session-store-transaction-queue' - -const publishFault = vi.hoisted(() => ({ armed: false })) - -vi.mock('../durable-file-write', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - renameDurable: async (tmpPath: string, finalPath: string) => { - if (publishFault.armed) { - publishFault.armed = false - throw new Error('simulated death before primary publish') - } - return actual.renameDurable(tmpPath, finalPath) - } - } -}) - -let root: string -let storePath: string - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-store-recovery-publish-')) - storePath = join(root, 'agent-sessions.json') -}) - -afterEach(async () => { - publishFault.armed = false - await rm(root, { recursive: true, force: true }) -}) - -function state(generation: number): AgentSessionStoreState { - return { - schemaVersion: 2, - hostId: 'local', - records: new Map(), - operations: new Map(), - retiredClaimKeys: [{ keyId: `generation-${generation}`, retiredAt: generation }], - unreadableRecords: new Map(), - sessionTabs: null - } -} - -describe('backup recovery publication', () => { - it('keeps the valid backup when publication fails after a corrupt primary was recovered', async () => { - await saveAgentSessionStore(storePath, state(1), { primaryStatus: 'unusable-or-absent' }) - await saveAgentSessionStore(storePath, state(2), { - primaryStatus: 'validated' - }) - await writeFile(storePath, '{corrupt-primary', 'utf-8') - const knownGoodBackup = await readFile(`${storePath}.bak`, 'utf-8') - - const recovered = await loadAgentSessionStore(storePath, 'local') - expect(recovered.recoveredFromBackup).toBe(true) - expect(recovered.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') - const queue = AgentSessionStoreTransactionQueue.fromLoadedStore( - storePath, - 'local', - recovered, - agentSessionStoreRevision(recovered.state) - ) - - publishFault.armed = true - await expect(queue.persistLoadedRewrite()).rejects.toThrow( - 'simulated death before primary publish' - ) - - expect(await readFile(`${storePath}.bak`, 'utf-8')).toBe(knownGoodBackup) - const afterFault = await loadAgentSessionStore(storePath, 'local') - expect(afterFault.recoveredFromBackup).toBe(true) - expect(afterFault.state.retiredClaimKeys[0]?.keyId).toBe('generation-1') - }) -}) diff --git a/src/main/runtime/agent-session-store-draft.ts b/src/main/runtime/agent-session-store-draft.ts new file mode 100644 index 000000000000..2afdfc13484b --- /dev/null +++ b/src/main/runtime/agent-session-store-draft.ts @@ -0,0 +1,135 @@ +/** + * A transaction applies to a draft of the published store state, and the store publishes the draft + * only once its rows have committed, so no reader ever sees a change that might still roll back. + */ + +import type { + AgentSessionStoreState, + RetiredAgentSessionClaimKey +} from './agent-session-record-store-file' +import { + isReadableAgentSessionStoreOperation, + isReadableAgentSessionStoreRecord, + isReadableAgentSessionStoreTab, + isReadableRetiredAgentSessionClaimKey +} from './agent-session-store-row-rules' +import type { PersistedAgentSessionTab } from './agent-session-tab-table' + +/** Unreadable rows are derived at load and never written, so the draft shares them. */ +export function draftAgentSessionStoreState(state: AgentSessionStoreState): AgentSessionStoreState { + return { + ...state, + records: new Map(state.records), + operations: new Map(state.operations), + retiredClaimKeys: [...state.retiredClaimKeys], + sessionTabs: state.sessionTabs?.clone() ?? null + } +} + +type KeyedRowWrites = { upsert: [key: string, json: string][]; remove: string[] } + +/** Exactly the rows a draft changed, each serialized as it will be stored. */ +export type AgentSessionStoreRowWrites = { + records: KeyedRowWrites + operations: KeyedRowWrites + /** The whole list when it changed, else null. */ + retiredClaimKeys: RetiredAgentSessionClaimKey[] | null + /** The whole index when it changed: `recorded: false` is a store that has never recorded one. */ + sessionTabs: { recorded: boolean; tabs: PersistedAgentSessionTab[] } | null +} + +/** A row as a load parses it back: JSON drops `undefined` members that an in-memory check sees. */ +function serializeChangedRows( + published: ReadonlyMap, + next: ReadonlyMap, + readable: (key: string, written: unknown) => boolean +): KeyedRowWrites { + const writes: KeyedRowWrites = { upsert: [], remove: [] } + let added = 0 + for (const [key, value] of next) { + const prior = published.get(key) + if (prior === value) { + continue + } + const json = JSON.stringify(value) + if (json === undefined || !readable(key, JSON.parse(json))) { + throw new Error('agent_session_store_write_invalid') + } + writes.upsert.push([key, json]) + added += prior === undefined && !published.has(key) ? 1 : 0 + } + // Only a size mismatch can hide a removal, so the common write never walks the published rows. + if (published.size + added !== next.size) { + for (const key of published.keys()) { + if (!next.has(key)) { + writes.remove.push(key) + } + } + } + return writes +} + +function retiredClaimKeysChanged( + published: readonly RetiredAgentSessionClaimKey[], + next: readonly RetiredAgentSessionClaimKey[] +): boolean { + return published.length !== next.length || published.some((entry, index) => entry !== next[index]) +} + +/** + * The rows a draft changed relative to the published state, by identity, or null when it changed + * none. Each changed row is checked as written against the rules a load applies, so a row a later + * load would refuse rejects its own transaction instead of living in memory unreadable. + */ +export function agentSessionStoreDraftRowWrites( + published: AgentSessionStoreState, + draft: AgentSessionStoreState +): AgentSessionStoreRowWrites | null { + const records = serializeChangedRows(published.records, draft.records, (sessionId, written) => + isReadableAgentSessionStoreRecord(sessionId, written) + ) + const operations = serializeChangedRows(published.operations, draft.operations, (key, written) => + isReadableAgentSessionStoreOperation(key, written) + ) + const retiredClaimKeys = retiredClaimKeysChanged( + published.retiredClaimKeys, + draft.retiredClaimKeys + ) + ? draft.retiredClaimKeys + : null + if ( + retiredClaimKeys && + (!retiredClaimKeys.every((entry) => + isReadableRetiredAgentSessionClaimKey(JSON.parse(JSON.stringify(entry))) + ) || + new Set(retiredClaimKeys.map((entry) => entry.keyId)).size !== retiredClaimKeys.length) + ) { + throw new Error('agent_session_store_write_invalid') + } + const tabsChanged = + published.sessionTabs && draft.sessionTabs + ? !published.sessionTabs.equals(draft.sessionTabs) + : published.sessionTabs !== draft.sessionTabs + const tabs = tabsChanged + ? (draft.sessionTabs?.entries() ?? []).map(([tabId, sessionId]) => ({ tabId, sessionId })) + : [] + if (!tabs.every(isReadableAgentSessionStoreTab)) { + throw new Error('agent_session_store_write_invalid') + } + const changed = + records.upsert.length > 0 || + records.remove.length > 0 || + operations.upsert.length > 0 || + operations.remove.length > 0 || + retiredClaimKeys !== null || + tabsChanged + if (!changed) { + return null + } + return { + records, + operations, + retiredClaimKeys, + sessionTabs: tabsChanged ? { recorded: draft.sessionTabs !== null, tabs } : null + } +} diff --git a/src/main/runtime/agent-session-store-row-rules.ts b/src/main/runtime/agent-session-store-row-rules.ts new file mode 100644 index 000000000000..6d2f4ddf6b2e --- /dev/null +++ b/src/main/runtime/agent-session-store-row-rules.ts @@ -0,0 +1,63 @@ +/** + * What makes one agent-session store row readable. A load applies these to every row it reads; a + * transaction applies them to every row it changed before committing, so the rows this build writes + * are exactly the rows a later load accepts. + */ + +import { + agentSessionOperationKey, + isAgentSessionOperationRow, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import { isAgentSessionId, isPersistedAgentSessionRecord } from '../../shared/agent-session-record' +import type { PersistedAgentSessionRecord } from '../../shared/agent-session-legacy-handoff-lease' +import { isAgentSessionSurfaceTabId } from '../../shared/agent-session-surface-tab-id' +import type { RetiredAgentSessionClaimKey } from './agent-session-record-store-file' +import type { PersistedAgentSessionTab } from './agent-session-tab-table' + +/** A record row a load keeps in `records` rather than quarantining. */ +export function isReadableAgentSessionStoreRecord( + sessionId: string, + value: unknown +): value is PersistedAgentSessionRecord { + return isPersistedAgentSessionRecord(value) && value.sessionId === sessionId +} + +export function isReadableAgentSessionStoreOperation( + key: string, + value: unknown +): value is AgentSessionOperationRow { + return ( + isAgentSessionOperationRow(value) && + key === agentSessionOperationKey(value.callerKey, value.operationId) + ) +} + +export function isReadableRetiredAgentSessionClaimKey( + entry: unknown +): entry is RetiredAgentSessionClaimKey { + return ( + typeof entry === 'object' && + entry !== null && + 'keyId' in entry && + typeof entry.keyId === 'string' && + entry.keyId.length > 0 && + entry.keyId.length <= 512 && + 'retiredAt' in entry && + typeof entry.retiredAt === 'number' && + Number.isSafeInteger(entry.retiredAt) && + entry.retiredAt >= 0 + ) +} + +/** One chat tab entry; a table is readable when every entry is and no id repeats. */ +export function isReadableAgentSessionStoreTab(entry: unknown): entry is PersistedAgentSessionTab { + return ( + typeof entry === 'object' && + entry !== null && + 'tabId' in entry && + isAgentSessionSurfaceTabId(entry.tabId) && + 'sessionId' in entry && + isAgentSessionId(entry.sessionId) + ) +} diff --git a/src/main/runtime/agent-session-store-serialization.ts b/src/main/runtime/agent-session-store-serialization.ts deleted file mode 100644 index adc93f85c011..000000000000 --- a/src/main/runtime/agent-session-store-serialization.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { AgentSessionStoreState } from './agent-session-record-store-file' -import { serializeAgentSessionTabTable } from './agent-session-tab-table' - -export function serializeAgentSessionStoreState(state: AgentSessionStoreState): string { - const records: Record = Object.create(null) - for (const [sessionId, record] of state.records) { - records[sessionId] = record - } - const serialized: Record = { - schemaVersion: state.schemaVersion, - hostId: state.hostId, - records, - operations: Object.fromEntries(state.operations), - retiredClaimKeys: state.retiredClaimKeys, - unusableRecords: Object.fromEntries(state.unreadableRecords) - } - if (state.sessionTabs) { - Object.assign(serialized, serializeAgentSessionTabTable(state.sessionTabs)) - } - return JSON.stringify(serialized) -} diff --git a/src/main/runtime/agent-session-store-transaction-queue.ts b/src/main/runtime/agent-session-store-transaction-queue.ts deleted file mode 100644 index 1a4c78e46720..000000000000 --- a/src/main/runtime/agent-session-store-transaction-queue.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' -import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' -import { raiseAgentSessionFencesAfterBackupRecovery } from './agent-session-backup-recovery-fence' -import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStoreRevision, - loadAgentSessionStore, - saveAgentSessionStore, - type AgentSessionStoreState, - type LoadedAgentSessionStore -} from './agent-session-record-store-file' -import { withFileTransactionLock } from '../file-transaction-lock' - -/** Latch fields older builds wrote. Nothing reads them, and dropping them keeps a lease this build - * writes back from carrying a stale latch to an older build after a downgrade. */ -type RetiredAgentSessionLeaseFields = { - processlessAt?: unknown - settlementRetryRequired?: unknown - settlementRetryId?: unknown -} - -function markLoadedLeasesUnreconciled(state: AgentSessionStoreState): void { - for (const [sessionId, record] of state.records) { - const lease: AgentSessionLease & RetiredAgentSessionLeaseFields = record.lease - const { - processlessAt: _processlessAt, - settlementRetryRequired: _settlementRetryRequired, - settlementRetryId: _settlementRetryId, - ...current - } = lease - state.records.set(sessionId, { - ...record, - lease: { ...current, unreconciled: true } - }) - } -} - -function mapEntriesMatch(left: ReadonlyMap, right: ReadonlyMap): boolean { - if (left.size !== right.size) { - return false - } - for (const [key, value] of left) { - if (right.get(key) !== value) { - return false - } - } - return true -} - -function agentSessionStoreStateChanged( - state: AgentSessionStoreState, - records: ReadonlyMap, - operations: ReadonlyMap, - retiredClaimKeys: AgentSessionStoreState['retiredClaimKeys'], - unreadableRecords: AgentSessionStoreState['unreadableRecords'], - sessionTabs: AgentSessionStoreState['sessionTabs'] -): boolean { - return ( - !mapEntriesMatch(state.records, records) || - !mapEntriesMatch(state.operations, operations) || - !mapEntriesMatch(state.unreadableRecords, unreadableRecords) || - (state.sessionTabs && sessionTabs - ? !state.sessionTabs.equals(sessionTabs) - : state.sessionTabs !== sessionTabs) || - state.retiredClaimKeys.length !== retiredClaimKeys.length || - state.retiredClaimKeys.some((entry, index) => entry !== retiredClaimKeys[index]) - ) -} - -export class AgentSessionStoreTransactionQueue { - private queue: Promise = Promise.resolve() - private diskRecoveredFromBackup: boolean - - constructor( - private readonly filePath: string, - readonly hostId: string, - readonly readOnly: boolean, - readonly recoveredFromBackup: boolean, - private diskStoreFound: boolean, - public state: AgentSessionStoreState, - private diskRevision: string, - private needsRewrite: boolean - ) { - this.diskRecoveredFromBackup = recoveredFromBackup - } - - static fromLoadedStore( - filePath: string, - hostId: string, - loaded: LoadedAgentSessionStore, - diskRevision: string - ): AgentSessionStoreTransactionQueue { - return new AgentSessionStoreTransactionQueue( - filePath, - hostId, - loaded.readOnly, - loaded.recoveredFromBackup, - loaded.storeFound, - loaded.state, - diskRevision, - loaded.needsRewrite - ) - } - - transact(apply: () => T): Promise { - const run = this.queue.then(() => - withFileTransactionLock(this.filePath, async () => { - if (this.readOnly) { - throw new Error('agent_session_legacy_required') - } - await this.refreshExternallyChangedState() - const records = new Map(this.state.records) - const operations = new Map(this.state.operations) - const retiredClaimKeys = [...this.state.retiredClaimKeys] - const unreadableRecords = new Map(this.state.unreadableRecords) - const sessionTabs = this.state.sessionTabs?.clone() ?? null - try { - // The lost commit may have granted a higher fence than the backup records show. Rather - // than refuse forever, raise every recovered fence clear of anything that commit could - // have minted, then continue in the same transaction. - const recovering = this.diskRecoveredFromBackup - if (recovering) { - raiseAgentSessionFencesAfterBackupRecovery(this.state) - } - const result = apply() - if ( - !recovering && - !this.needsRewrite && - !agentSessionStoreStateChanged( - this.state, - records, - operations, - retiredClaimKeys, - unreadableRecords, - sessionTabs - ) - ) { - return result - } - await saveAgentSessionStore(this.filePath, this.state, { - primaryStatus: this.diskStoreFound && !recovering ? 'validated' : 'unusable-or-absent' - }) - this.state.schemaVersion = AGENT_SESSION_STORE_SCHEMA_VERSION - this.diskRevision = agentSessionStoreRevision(this.state) - this.diskRecoveredFromBackup = false - this.diskStoreFound = true - this.needsRewrite = false - return result - } catch (error) { - this.state.records = records - this.state.operations = operations - this.state.retiredClaimKeys = retiredClaimKeys - this.state.unreadableRecords = unreadableRecords - this.state.sessionTabs = sessionTabs - throw error - } - }) - ) - this.queue = run.catch(() => {}) - return run - } - - persistLoadedRewrite(): Promise { - return this.transact(() => undefined) - } - - private async refreshExternallyChangedState(): Promise { - const loaded = await loadAgentSessionStore(this.filePath, this.hostId) - if (this.diskStoreFound && !loaded.storeFound) { - throw new Error('agent_session_store_corrupt') - } - this.diskStoreFound ||= loaded.storeFound - const diskRevision = agentSessionStoreRevision(loaded.state) - this.diskRecoveredFromBackup = loaded.recoveredFromBackup - if (diskRevision === this.diskRevision) { - this.needsRewrite ||= loaded.needsRewrite - return - } - if (loaded.readOnly) { - throw new Error('agent_session_legacy_required') - } - markLoadedLeasesUnreconciled(loaded.state) - this.state = loaded.state - this.diskRevision = diskRevision - this.needsRewrite = loaded.needsRewrite - } -} - -export function markAgentSessionStoreLeasesUnreconciled(state: AgentSessionStoreState): void { - markLoadedLeasesUnreconciled(state) -} diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts new file mode 100644 index 000000000000..b4b57f30d89a --- /dev/null +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -0,0 +1,164 @@ +// Every agent-session store mutation: draft the published state, apply, check and write exactly the +// changed rows in one journal transaction, then publish the draft. A throw anywhere discards the +// draft, so memory never holds a change the database does not. +// +// Why a promise queue although the body is synchronous: a store call must never run its BEGIN in +// the caller's frame. A caller inside an open journal transaction on the same connection would nest +// a BEGIN, which `runJournalTransaction` does not support. The queue also keeps the FIFO order and +// the async boundary every awaiting caller was written against. + +import type Database from '../sqlite/sync-database' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { + classifyJournalOpenFailure, + journalOpenRefusalError +} from '../native-chat/agent-session-journal/journal-open-failure' +import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' +import type { AgentSessionStoreState } from './agent-session-record-store-file' +import { writeAgentSessionStoreRows } from './agent-session-record-rows' +import { + agentSessionStoreDraftRowWrites, + draftAgentSessionStoreState, + type AgentSessionStoreRowWrites +} from './agent-session-store-draft' + +// Why: rows are diffed by identity, so a row changed in place would never be written. Tests and +// development builds make that a TypeError; packaged builds skip the walk. +const FREEZE_ROWS = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test' + +function deepFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) { + return + } + Object.freeze(value) + for (const child of Object.values(value)) { + deepFreeze(child) + } +} + +function freezeRows( + state: AgentSessionStoreState, + writes: AgentSessionStoreRowWrites | null +): void { + if (!FREEZE_ROWS) { + return + } + const records = writes ? writes.records.upsert.map(([key]) => key) : state.records.keys() + for (const sessionId of records) { + deepFreeze(state.records.get(sessionId)) + } + const operations = writes ? writes.operations.upsert.map(([key]) => key) : state.operations.keys() + for (const key of operations) { + deepFreeze(state.operations.get(key)) + } + if (!writes || writes.retiredClaimKeys) { + state.retiredClaimKeys.forEach(deepFreeze) + } +} + +/** What every write gets on a database a newer Orca wrote: the refusal clients print as "update". */ +function readOnlyStoreRefusal(): Error { + return journalOpenRefusalError( + new AgentSessionJournalError( + 'journal_read_only', + 'the chat records were saved by a newer Orca; this host reads them and never writes' + ) + ) +} + +type StagedStoreTransaction = { + result: T + writes: AgentSessionStoreRowWrites | null + /** Publishes the draft. Only once its rows have committed. */ + adopt: () => void +} + +export class AgentSessionStoreTransactions { + private queue: Promise = Promise.resolve() + private published: AgentSessionStoreState + + constructor( + private readonly journalDatabase: JournalHostDatabase, + loaded: AgentSessionStoreState + ) { + freezeRows(loaded, null) + this.published = loaded + } + + /** The committed state. A transaction in flight never shows here until its rows have landed. */ + get state(): AgentSessionStoreState { + return this.published + } + + get readOnly(): boolean { + return this.journalDatabase.readOnly + } + + /** + * `apply` changes only the draft it is given. On a database a newer Orca wrote every transaction + * is refused, except one marked `inMemoryWhenReadOnly`: it is published without a write, for a + * verdict re-derived at every start. + */ + transact( + apply: (draft: AgentSessionStoreState) => T, + options: { inMemoryWhenReadOnly?: boolean } = {} + ): Promise { + const run = this.queue.then(() => this.commit(apply, options.inMemoryWhenReadOnly === true)) + this.queue = run.catch(() => {}) + return run + } + + /** + * The same synchronous body for a caller already inside a journal transaction on `db`, so a + * record change can commit with the journal rows it belongs with. Call `adopt` only after that + * transaction commits. + */ + applyInTransaction( + db: Database.Database, + apply: (draft: AgentSessionStoreState) => T + ): { result: T; adopt: () => void } { + const staged = this.stage(apply) + if (staged.writes) { + writeAgentSessionStoreRows(db, staged.writes) + } + return { result: staged.result, adopt: staged.adopt } + } + + private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { + const readOnly = this.journalDatabase.readOnly + if (readOnly && !inMemoryWhenReadOnly) { + throw readOnlyStoreRefusal() + } + const staged = this.stage(apply) + const writes = staged.writes + if (writes && !readOnly) { + try { + this.journalDatabase.transaction((db) => writeAgentSessionStoreRows(db, writes)) + } catch (error) { + // Damage SQLite proves is the chat database's: the refusal a journal write gives for it. + throw classifyJournalOpenFailure(error) === 'journalCorrupt' + ? journalOpenRefusalError(error) + : error + } + } + staged.adopt() + return staged.result + } + + private stage(apply: (draft: AgentSessionStoreState) => T): StagedStoreTransaction { + const published = this.published + const draft = draftAgentSessionStoreState(published) + const result = apply(draft) + const writes = agentSessionStoreDraftRowWrites(published, draft) + return { + result, + writes, + adopt: () => { + if (writes) { + freezeRows(draft, writes) + this.published = draft + } + } + } + } +} diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index 840356cf0cf0..fb36c559a8ed 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -145,23 +145,10 @@ export function showAgentSessionTabs( export type PersistedAgentSessionTab = { tabId: string; sessionId: string } -export function serializeAgentSessionTabTable(table: AgentSessionTabTable): { - sessionTabs: PersistedAgentSessionTab[] - visibleSessionIds: string[] -} { - return { - sessionTabs: table.entries().map(([tabId, sessionId]) => ({ tabId, sessionId })), - // Written for older builds, which restore tabs from this list; never read beside the table. - visibleSessionIds: table.sessionIds() - } -} - /** - * Reads the persisted table, or seeds it from what older builds wrote: the visible session list and, - * for a chat created by a build that recorded one, the tab id on its record. That record field is - * read here and nowhere else, and only when the file carries no table. - * - * Deterministic on purpose: a parsed store must hash the same on every read of the same bytes. + * Reads the records file's table, or seeds it from what older builds wrote: the visible session list + * and, for a chat created by a build that recorded one, the tab id on its record. That record field + * is read here and nowhere else, and only when the file carries no table. */ export function parseAgentSessionTabTable( file: { sessionTabs?: unknown; visibleSessionIds?: unknown }, diff --git a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts b/src/main/runtime/agent-session-unreadable-record-salvage.test.ts deleted file mode 100644 index cde4091d7ebf..000000000000 --- a/src/main/runtime/agent-session-unreadable-record-salvage.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -// Unreadable session records: quarantine when nothing can vouch for the session, salvage -// when the previous committed state can. - -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionRecord } from '../../shared/agent-session-record' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { agentSessionStorePath } from './agent-session-record-store-file' -import type { AgentSessionReserveRequest } from './agent-session-reservation-admission' - -const NOW = 1_800_000_000_000 -let directory: string -let counter = 0 - -function operationId(): string { - counter += 1 - return `${NOW}-${String(counter) - .padStart(32, '0') - .replaceAll(/[^0-9a-f]/g, '0')}` -} - -function reserveRequest( - overrides: Partial = {} -): AgentSessionReserveRequest { - return { - sessionId: 'session-alpha', - location: { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'git-worktree' - }, - provider: 'claude', - accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude-work' }, - expectedFence: null, - spawnToken: 'spawn-a', - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'indeterminate', reason: 'no answer' }, - operation: { callerKey: 'client-1', operationId: operationId(), fingerprint: 'fp-1' }, - now: NOW, - ...overrides - } -} - -async function open(): Promise { - return AgentSessionRecordStore.open({ directory, hostId: 'local' }) -} - -/** Reserve, observe the spawn, prove the handle — the full path to an admitted writer. */ -async function establishOwner(store: AgentSessionRecordStore): Promise { - const reserved = await store.reserveOwner(reserveRequest()) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: 'session-alpha', - fence, - process: { - hostId: 'local', - pid: 4242, - processStartTimeMs: 1_700_000_000_000, - spawnToken: reserved.record.lease.reservedSpawnToken ?? 'spawn-a' - }, - now: NOW - }) - return store.proveOwner({ - sessionId: 'session-alpha', - fence, - link: { - linkId: 'link-1', - handle: { provider: 'claude', sessionId: 'provider-session-1', leafUuid: 'leaf-1' }, - origin: 'created', - mintedAtFence: fence, - observedAt: NOW - }, - now: NOW - }) -} - -async function corruptPrimaryRecord(): Promise { - const filePath = agentSessionStorePath(directory) - const raw = JSON.parse(await readFile(filePath, 'utf-8')) - raw.records['session-alpha'].lease.runtimeFence = 'not-a-number' - await writeFile(filePath, JSON.stringify(raw)) - return filePath -} - -beforeEach(async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-agent-session-salvage-')) -}) - -afterEach(async () => { - await rm(directory, { recursive: true, force: true }) -}) - -describe('unreadable session records', () => { - it('quarantines an unreadable record with no committed copy and refuses to own it', async () => { - const first = await open() - await establishOwner(first) - const filePath = await corruptPrimaryRecord() - // No previous committed state survives, so nothing can vouch for the session. - await rm(`${filePath}.bak`, { force: true }) - - const reopened = await open() - expect(reopened.getRecord('session-alpha')).toBeNull() - expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) - await expect(reopened.reserveOwner(reserveRequest())).rejects.toThrow( - 'execution_owner_reconciling' - ) - // The row stays verbatim inside an explicit unusable envelope. - await reopened.retireClaimKey('key-2', NOW) - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records).not.toHaveProperty('session-alpha') - expect(persisted.unusableRecords['session-alpha']).toMatchObject({ - reason: 'current_shape_invalid', - raw: { lease: { runtimeFence: 'not-a-number' } } - }) - }) - - it('salvages the last committed copy of a record the primary retains as unreadable', async () => { - const first = await open() - await establishOwner(first) - // One more commit leaves the proven-owner record in the backup file. - await first.setJournalCheckpoint({ - sessionId: 'session-alpha', - fence: 1, - checkpoint: { epoch: 1, sequence: 1 }, - now: NOW - }) - const filePath = await corruptPrimaryRecord() - - const reopened = await open() - // The previous committed state vouches for the session; its lease is re-adjudicated - // like any other, and the unreadable bytes stay quarantined verbatim. - expect(reopened.getRecord('session-alpha')?.lease).toMatchObject({ - runtimeFence: 1, - claimStatus: 'live' - }) - expect(reopened.recoveredFromBackup).toBe(false) - expect(reopened.isSessionUnreadable('session-alpha')).toBe(true) - await reopened.reconcileOnRestart({ - probe: async () => ({ outcome: 'pid-absent' }), - now: NOW + 1 - }) - expect(reopened.getRecord('session-alpha')?.lease.claimStatus).toBe('released') - - // Ownership is reachable again instead of refused with execution_owner_reconciling. - const reserved = await reopened.reserveOwner( - reserveRequest({ expectedFence: 2, spawnToken: 'spawn-b' }) - ) - expect(reserved.record.lease.claimStatus).toBe('reserved') - const persisted = JSON.parse(await readFile(filePath, 'utf-8')) - expect(persisted.records['session-alpha'].lease.claimStatus).toBe('reserved') - expect(persisted.unusableRecords['session-alpha']).toMatchObject({ - reason: 'current_shape_invalid' - }) - - // Salvage only fills gaps: with the live record readable again, a reload must never - // let the stale backup copy clobber it. - const reloaded = await open() - expect(reloaded.getRecord('session-alpha')?.lease).toMatchObject({ - claimStatus: 'reserved', - runtimeFence: 3 - }) - }) -}) diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 568083bf177f..b0a584182e07 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -20,7 +20,11 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi const host = getStructuredAgentSessionHost() if (host) { if (typeof host.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility(tab.sessionId, false) + // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a + // failing disk) is reported, and the tab still closes. + await host.setSessionTabVisibility(tab.sessionId, false).catch((error: unknown) => { + console.warn('[structured-agent-session] recording a closed chat tab failed', error) + }) } } // Retire durable visibility and the runtime snapshot before stopping the provider. diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 1eea896b5dce..423c3c6d0206 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -92,9 +92,12 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu this.projectStructuredAgentSessionTab({ ...session, activate: false, notify: false }) } const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable - // No host means no one can say which chats exist; with none on disk, empty is the answer. + // No host, or one still owed the records file's chats, means no one can say which chats exist; + // with none on disk, empty is the answer. + const importOwed = + typeof host?.legacyRecordImportOwed === 'function' && host.legacyRecordImportOwed() this.structuredAgentSessionInventoryUnverifiable = - !host && this.hasPersistedStructuredAgentSessionStore() + (!host || importOwed) && this.hasPersistedStructuredAgentSessionStore() // This restore published quietly; subscribers still hold the frames that said "cannot tell". if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { this.notifyMobileSessionTabSnapshots() diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index 2cb953063391..e2fb7c767d11 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -7,7 +7,7 @@ * harness rather than of the guard. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,10 +26,10 @@ import { } from '../../../../shared/agent-session-operation-ledger' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { + editPersistedTestAgentSessionStore, openTestAgentSessionRecordStore, readPersistedTestAgentSessionStore } from '../../agent-session-record-store-test-harness' -import { agentSessionStorePath } from '../../agent-session-record-store-file' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { RpcContext } from '../core' @@ -431,18 +431,12 @@ describe('the recorded row stays readable by a build that predates it', () => { }) describe('an unreadable launch payload costs one replay, never the store', () => { - /** The whole file, primary and backup: `loadAgentSessionStore` falls through to the backup, and - * the backup is a copy of the validated primary, so both carry the same payload in real life. */ + /** The recorded operation row, as a build that wrote another payload shape leaves it. */ async function rewriteRecordedLaunch(payload: unknown): Promise { - const path = agentSessionStorePath(directory) - const file: { operations: Record }> } = JSON.parse( - await readFile(path, 'utf-8') - ) - const row = Object.values(file.operations)[0] - row.outcome.launch = payload - const written = JSON.stringify(file) - await writeFile(path, written) - await writeFile(`${path}.bak`, written) + await editPersistedTestAgentSessionStore(directory, (persisted) => { + const row: { outcome: Record } = Object.values(persisted.operations)[0] + row.outcome.launch = payload + }) } it('still admits the row, because one rejected row makes the whole file unparseable', () => { diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 4d6a313f8b5b..02673e31e624 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -3,7 +3,7 @@ * the real structured host, the real runtime, and the real RPC handlers. Only the provider is faked. */ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,7 +26,6 @@ import { import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionRecordStore } from '../../agent-session-record-store' import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' -import { agentSessionStorePath } from '../../agent-session-record-store-file' import { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' @@ -281,27 +280,6 @@ describe('a chat tab across /clear', () => { sessionIds: [replacement] }) }) - - it('gives a reopened cleared conversation the same id when an older build drops the table', async () => { - await createChat(HOST_TEST_SESSION) - const first = await clear(HOST_TEST_SESSION) - await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION }) - const current = await clear(first) - const reopenedTab = store.getSessionTabId(HOST_TEST_SESSION) - expect(reopenedTab).not.toBeNull() - await host.flushAllStreamedEvents() - - // An older build rewrites the file from what it read, which drops the table. - const file = agentSessionStorePath(join(directory, 'store')) - const raw = JSON.parse(await readFile(file, 'utf-8')) - expect(raw.sessionTabs).toHaveLength(2) - delete raw.sessionTabs - await writeFile(file, JSON.stringify(raw)) - - await openHost() - expect(store.getSessionTabId(current)).toBe(SOURCE_TAB) - expect(store.getSessionTabId(HOST_TEST_SESSION)).toBe(reopenedTab) - }) }) describe('session tab mutations from other clients, unchanged by the table', () => { diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index 689ac3df969c..d05c9a78422a 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -122,7 +122,7 @@ describe('a process whose journal will not open', () => { const logged = String(warn.mock.calls[0]?.[1]) expect(logged).toContain(path) expect(logged).toContain('unreleased development build') - expect(logged).toContain('move the file aside') + expect(logged).toContain('Moving the file aside') expect(await digest(path)).toBe(before) }) @@ -353,19 +353,19 @@ describe('startup and other non-chat work without a structured host', () => { ).resolves.toBeUndefined() await expect( assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { - throw new Error('the record store would not open') + throw new Error('the host would not construct') }) - ).rejects.toThrow('the record store would not open') + ).rejects.toThrow('the host would not construct') expect(gateRefusal().reason).toBe('journalCorrupt') }) it('still fails on an install error that refuses nothing', async () => { const { runtime, refreshPtyRecords } = startupRuntime(async () => { - throw new Error('the record store would not open') + throw new Error('the host would not construct') }) await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).rejects.toThrow( - 'the record store would not open' + 'the host would not construct' ) expect(refreshPtyRecords).not.toHaveBeenCalled() }) diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts index 53720659ec41..4b00cdebda60 100644 --- a/src/main/runtime/structured-agent-session-install-failure.test.ts +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -57,12 +57,13 @@ describe('an install that fails after opening the chat journal', () => { mocks.failWiring.mockReturnValueOnce(true) await expect(install()).rejects.toThrow('model catalog wiring failed') - const failed = open.mock.results[0]?.value + const failed = await open.mock.results[0]?.value expect(failed).toBeInstanceOf(JournalHostDatabase) expect(failed.isClosed).toBe(true) await expect(install()).resolves.toBeDefined() expect(open).toHaveBeenCalledTimes(2) - expect(open.mock.results[1]?.value.isClosed).toBe(false) + const reopened = await open.mock.results[1]?.value + expect(reopened?.isClosed).toBe(false) }) }) diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 6972a18a7960..5643410ceafa 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -1,7 +1,13 @@ -// Opening the chat journal database for the host install. +// Opening the chat journal database for the host install. The open that migrates it to version 4 +// first reads the records file it replaces, so the copy runs in the migration's transaction. import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { + legacyAgentSessionRecordImport, + readLegacyAgentSessionRecords, + type LegacyAgentSessionRecordImportReport +} from './agent-session-legacy-record-import' import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. @@ -20,12 +26,21 @@ function logOpenFailureOnce(error: unknown): void { } /** The journal database. A refusal is recorded for the gate and thrown to the caller; the next - * install tries again. */ -export function openStructuredAgentSessionJournalDatabase( + * install tries again. `legacyRecordsDirectory` is where the records file lived. */ +export async function openStructuredAgentSessionJournalDatabase(args: { stateDirectory: string -): JournalHostDatabase { + legacyRecordsDirectory: string + hostId: string + onLegacyRecordImportReport: (report: LegacyAgentSessionRecordImportReport) => void +}): Promise { try { - const opened = JournalHostDatabase.open(stateDirectory) + const opened = await JournalHostDatabase.open(args.stateDirectory, async () => + legacyAgentSessionRecordImport( + await readLegacyAgentSessionRecords(args.legacyRecordsDirectory, args.hostId), + args.hostId, + args.onLegacyRecordImportReport + ) + ) recordStructuredAgentSessionHostInstallRefusal(null) lastLoggedOpenFailure = null return opened diff --git a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts index 48a9d33142c2..659f645bf4a8 100644 --- a/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts +++ b/src/main/runtime/structured-agent-session-rollback-compatibility.test.ts @@ -7,9 +7,9 @@ import { safeParseWorkspaceSession } from '../../shared/workspace-session-schema import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore, seedTestAgentSessionRecordStore } from './agent-session-record-store-test-harness' -import { AGENT_SESSION_STORE_FILE_NAME } from './agent-session-record-store-file' import { collectSavedStructuredAgentSessionIds } from './saved-structured-agent-session-restoration' const WORKSPACE = 'workspace-1' @@ -124,9 +124,9 @@ describe('structured session rollback compatibility', () => { await expect(readFile(join(journalDir, 'journal.log'), 'utf8')).resolves.toBe( 'durable-journal-fixture\n' ) - expect( - JSON.parse(await readFile(join(storeDir, AGENT_SESSION_STORE_FILE_NAME), 'utf8')) - ).toMatchObject({ visibleSessionIds: [SESSION] }) + expect((await readPersistedTestAgentSessionStore(storeDir)).sessionTabs).toEqual([ + { tabId: expect.any(String), sessionId: SESSION } + ]) await reloaded.setSessionTabVisibility(SESSION, false) const afterClose = await openTestAgentSessionRecordStore(storeDir) diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 3e2a1f14b869..a6a1ff9717af 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -54,35 +54,34 @@ const OWNER: AgentSessionProcessIdentity = { const deadProbe = () => vi.fn(async () => ({ outcome: 'pid-absent' }) as const) describe('structured agent-session store presence', () => { - it('stops after finding the durable primary store', () => { + it('stops after finding the chat journal database', () => { const fileExists = vi.fn(() => true) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) expect(fileExists).toHaveBeenCalledOnce() - expect(fileExists).toHaveBeenCalledWith( - join('/profile', 'agent-sessions', 'agent-sessions.json') - ) + expect(fileExists).toHaveBeenCalledWith(join('/profile', 'agent-session-journal.db')) }) - it('checks the durable backup when the primary store is absent', () => { + // A profile from before the records moved into the database still holds a chat to import. + it('checks the records file and its backup when the database is absent', () => { const fileExists = vi.fn((path: string) => path.endsWith('.bak')) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) expect(fileExists).toHaveBeenNthCalledWith( - 1, + 2, join('/profile', 'agent-sessions', 'agent-sessions.json') ) expect(fileExists).toHaveBeenNthCalledWith( - 2, + 3, join('/profile', 'agent-sessions', 'agent-sessions.json.bak') ) }) - it('reports a fresh profile absent after two bounded presence checks', () => { + it('reports a fresh profile absent after three bounded presence checks', () => { const fileExists = vi.fn(() => false) expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(false) - expect(fileExists).toHaveBeenCalledTimes(2) + expect(fileExists).toHaveBeenCalledTimes(3) }) }) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 0c6b392970d4..4f5f73e97882 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -43,6 +43,7 @@ import { AgentSessionRecordStore } from './agent-session-record-store' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { agentSessionStorePath } from './agent-session-record-store-file' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -58,15 +59,22 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -/** Beside the journal database: one file adjudicates every session's lease. */ -const RECORD_STORE_DIR_NAME = 'agent-sessions' +/** Where the records file lived before the records moved into the journal database; read once, + * by that database's version-4 migration, and never written. */ +const LEGACY_RECORD_STORE_DIR_NAME = 'agent-sessions' +/** Whether this profile ever held a structured chat: the journal database, or the records file a + * profile from before it still carries. */ export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { - const filePath = agentSessionStorePath(join(stateDirectory, RECORD_STORE_DIR_NAME)) - return fileExists(filePath) || fileExists(`${filePath}.bak`) + const filePath = agentSessionStorePath(join(stateDirectory, LEGACY_RECORD_STORE_DIR_NAME)) + return ( + fileExists(journalDatabasePath(stateDirectory)) || + fileExists(filePath) || + fileExists(`${filePath}.bak`) + ) } export type StructuredAgentSessionRuntimeDeps = { @@ -194,7 +202,15 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise + deps.onError + ? deps.onError({ scope: 'structured-agent-session-record-import', error: report }) + : console.warn('[structured-agent-session] importing the chat records file', report) + }) try { return await installOnJournal(deps, journalDatabase) } catch (error) { @@ -210,10 +226,7 @@ async function installOnJournal( ): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers - const store = await AgentSessionRecordStore.open({ - directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME), - hostId: deps.hostId - }) + const store = AgentSessionRecordStore.open({ journalDatabase, hostId: deps.hostId }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event), diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 33b26f7569bb..71f0d28e6488 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -1,23 +1,42 @@ -// A profile whose chat records a newer Orca wrote opens read-only. The startup reconcile cannot -// write there, which is bookkeeping: startup must still finish rather than put the whole app into -// its degraded "Session restore failed" mode, and the file must be left exactly as it was. +// A profile whose chat records a newer Orca wrote opens read-only. The startup reconcile adjudicates +// its leases in memory and writes nothing there; a reconcile whose write fails is bookkeeping too. +// Either way startup must finish rather than put the whole app into its degraded "Session restore +// failed" mode. -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { readFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import type * as AgentSessionRecordRows from './agent-session-record-rows' import { - AGENT_SESSION_STORE_SCHEMA_VERSION, - agentSessionStorePath -} from './agent-session-record-store-file' + seedTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild +} from './agent-session-record-store-test-harness' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { OrcaRuntimeService } from './orca-runtime' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' +const writes = vi.hoisted(() => ({ failing: false })) + +vi.mock('./agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing) { + throw new Error('disk I/O error') + } + return actual.writeAgentSessionStoreRows(...args) + } + } +}) + let root: string beforeEach(async () => { @@ -25,29 +44,22 @@ beforeEach(async () => { }) afterEach(async () => { + writes.failing = false await stopStructuredAgentSessionRuntime().catch(() => undefined) + closeTestJournalHostDatabases() vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) -/** A profile whose record store a newer Orca wrote, with one chat in it. */ -async function seedNewerStore() { - const storeDirectory = join(root, 'agent-sessions') - const path = agentSessionStorePath(storeDirectory) +/** A profile with one chat whose last owner is gone; `newer` stamps it as a newer Orca's. */ +async function seedChat(options: { newer?: boolean } = {}) { const record = agentSessionRecordFixture() - await mkdir(storeDirectory, { recursive: true }) - await writeFile( - path, - JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + 1, - hostId: 'local', - records: { [record.sessionId]: record }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - ) - return { storeDirectory, path, sessionId: record.sessionId } + await seedTestAgentSessionRecordStore(root, { records: [record] }) + if (options.newer) { + await seedTestAgentSessionStoreFromNewerBuild(root) + } + closeTestJournalHostDatabases() + return { path: journalDatabasePath(root), sessionId: record.sessionId } } function startupRuntime(onError?: (input: { scope: string; error: unknown }) => void) { @@ -73,31 +85,45 @@ function startupRuntime(onError?: (input: { scope: string; error: unknown }) => return runtime } -it('finishes startup over records a newer Orca wrote, reports it, and writes nothing', async () => { - const { storeDirectory, path, sessionId } = await seedNewerStore() +it('finishes startup over records a newer Orca wrote, reads them, and writes nothing', async () => { + const { path, sessionId } = await seedChat({ newer: true }) const bytes = await readFile(path) - const files = await readdir(storeDirectory) const onError = vi.fn() const runtime = startupRuntime(onError) // What the renderer's startup awaits through `app:prepareTerminalStartupRestoration`. await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + expect(onError).not.toHaveBeenCalled() + const host = getStructuredAgentSessionHost() + expect(host?.sessionAgent(sessionId)).toBe('claude') + // Adjudicated in memory only: the verdict is re-derived at the next start. + expect(host?.deps.store.getRecord(sessionId)?.lease.unreconciled).toBe(false) + await stopStructuredAgentSessionRuntime() + expect(await readFile(path)).toEqual(bytes) +}) + +it('finishes startup when the reconcile cannot write, and reports it', async () => { + const { sessionId } = await seedChat() + const onError = vi.fn() + const runtime = startupRuntime(onError) + writes.failing = true + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + expect(onError).toHaveBeenCalledOnce() expect(onError).toHaveBeenCalledWith({ scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining({ message: 'agent_session_legacy_required' }) + error: expect.objectContaining({ message: 'disk I/O error' }) }) expect(getStructuredAgentSessionHost()?.sessionAgent(sessionId)).toBe('claude') - await stopStructuredAgentSessionRuntime() - expect(await readFile(path)).toEqual(bytes) - expect(await readdir(storeDirectory)).toEqual(files) }) // The desktop installs its host with no error sink, so the failure is logged rather than dropped. it('logs the failure when the host has no error sink', async () => { - await seedNewerStore() + await seedChat() const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + writes.failing = true await expect( startupRuntime().prepareStructuredAgentSessionStartupRestoration() @@ -105,6 +131,6 @@ it('logs the failure when the host has no error sink', async () => { expect(warn).toHaveBeenCalledWith( '[structured-agent-session] reconciling chat leases failed', - expect.objectContaining({ message: 'agent_session_legacy_required' }) + expect.objectContaining({ message: 'disk I/O error' }) ) }) diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 77797a5f531b..52faa7094671 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -1,9 +1,9 @@ // With native chat on, the renderer's startup also awaits the chat tab restore (`session.tabs.listAll`), // which reads every chat whose tab was open at quit. That read must not wait on record-store // bookkeeping: with a saved tab index it writes nothing, and a store that cannot be written costs -// a bounded number of lock waits, not one per chat. +// a bounded number of failed writes, not one per chat. -import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,40 +13,43 @@ import { agentSessionLeaseFixture, agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' -import type * as FileTransactionLock from '../file-transaction-lock' -import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { closeTestJournalHostDatabases } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' import { journalIdentityFor } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { attachParamsForRecord } from '../native-chat/agent-session-wire/structured-agent-session-conversation-open' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import { AgentSessionRecordStore } from './agent-session-record-store' +import type * as AgentSessionRecordRows from './agent-session-record-rows' import { AGENT_SESSION_STORE_SCHEMA_VERSION, agentSessionStorePath } from './agent-session-record-store-file' +import { + readPersistedTestAgentSessionStore, + seedTestAgentSessionStoreFromNewerBuild +} from './agent-session-record-store-test-harness' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { OrcaRuntimeService } from './orca-runtime' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime } from './structured-agent-session-runtime' -// `failing` refuses every take; `grants` lets that many more through, then refuses. -const lock = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) +// `failing` fails every record write; `grants` lets that many more through, then fails. +const writes = vi.hoisted(() => ({ failing: false, grants: Infinity, refused: 0 })) -vi.mock('../file-transaction-lock', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('./agent-session-record-rows', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, - withFileTransactionLock: (...args: Parameters) => { - if (lock.failing || lock.grants <= 0) { - lock.refused += 1 - // What proper-lockfile throws once its retries give up, about 3 s later. - return Promise.reject( - Object.assign(new Error('Lock file is already being held'), { code: 'ELOCKED' }) - ) + writeAgentSessionStoreRows: (...args: Parameters) => { + if (writes.failing || writes.grants <= 0) { + writes.refused += 1 + throw new Error('disk I/O error') } - lock.grants -= 1 - return actual.withFileTransactionLock(...args) + writes.grants -= 1 + return actual.writeAgentSessionStoreRows(...args) } } }) @@ -63,8 +66,9 @@ beforeEach(async () => { }) afterEach(async () => { - Object.assign(lock, { failing: false, grants: Infinity, refused: 0 }) + Object.assign(writes, { failing: false, grants: Infinity, refused: 0 }) await stopStructuredAgentSessionRuntime().catch(() => undefined) + closeTestJournalHostDatabases() vi.restoreAllMocks() await rm(root, { recursive: true, force: true }) }) @@ -107,18 +111,20 @@ function chatRecord( return { ...record, ...codex, ...clear } } -/** Writes the record store; `visible` is the saved tab index, absent on a legacy profile. */ -async function seedStore( +/** The records file a profile from before the chat database carries, which the install imports; + * `visible` is its saved tab index, absent on a legacy profile. `newer` instead leaves the records + * in a database a newer Orca wrote. Then each chat's history as the last run left it: one prompt, + * accepted, so nothing is left to send. */ +async function seedProfile( records: AgentSessionRecord[], - options: { newer?: boolean; visible?: string[] } = {} + options: { newer?: boolean; visible?: string[]; history?: AgentSessionRecord[] } = {} ) { const storeDirectory = join(root, 'agent-sessions') - const path = agentSessionStorePath(storeDirectory) await mkdir(storeDirectory, { recursive: true }) await writeFile( - path, + agentSessionStorePath(storeDirectory), JSON.stringify({ - schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION + (options.newer ? 1 : 0), + schemaVersion: AGENT_SESSION_STORE_SCHEMA_VERSION, hostId: 'local', records: Object.fromEntries(records.map((record) => [record.sessionId, record])), operations: {}, @@ -127,13 +133,13 @@ async function seedStore( ...(options.visible ? { visibleSessionIds: options.visible } : {}) }) ) - return { storeDirectory, path } -} - -/** Each chat's history as the last run left it: one prompt, accepted, so nothing is left to send. */ -async function seedHistory(records: AgentSessionRecord[]): Promise { - const database = JournalHostDatabase.open(root) - for (const record of records) { + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + legacyRecordsDirectory: storeDirectory, + hostId: 'local', + onLegacyRecordImportReport: () => undefined + }) + for (const record of options.history ?? records) { const fence = record.lease.runtimeFence const journal = await openAgentSessionJournal({ identity: journalIdentityFor( @@ -158,6 +164,10 @@ async function seedHistory(records: AgentSessionRecord[]): Promise { await journal.close() } database.close() + if (options.newer) { + await seedTestAgentSessionStoreFromNewerBuild(root) + } + return { path: journalDatabasePath(root) } } function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { @@ -229,67 +239,73 @@ function spyOnTabWrites() { } } -async function tabIndexOnDisk(path: string): Promise { - return JSON.parse(await readFile(path, 'utf-8')).visibleSessionIds +async function tabIndexOnDisk(): Promise { + return (await readPersistedTestAgentSessionStore(root)).sessionTabs?.map((tab) => tab.sessionId) } describe('restoring the chat tabs open at quit', () => { it.each([ - { store: 'records a newer Orca wrote', newer: true, lockFails: false }, - { store: 'a lock that keeps failing', newer: false, lockFails: true } - ])('lists and reads every chat from $store, and writes nothing', async ({ newer, lockFails }) => { - const records = [ - chatRecord(CHAT_A), - chatRecord(CHAT_B), - chatRecord(CLEARED, { clearedInto: CHAT_A }) - ] - const { storeDirectory, path } = await seedStore(records, { newer, visible: [CHAT_A, CHAT_B] }) - await seedHistory(records.slice(0, 2)) - const bytes = await readFile(path) - const files = await readdir(storeDirectory) - const writes = spyOnTabWrites() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - // Free when the store opens, then held for good. - const { runtime, onError, published } = startupRuntime({ - afterInstall: () => { - lock.failing = lockFails - } - }) + { store: 'records a newer Orca wrote', newer: true, writesFail: false }, + { store: 'a store whose writes keep failing', newer: false, writesFail: true } + ])( + 'lists and reads every chat from $store, and writes nothing', + async ({ newer, writesFail }) => { + const records = [ + chatRecord(CHAT_A), + chatRecord(CHAT_B), + chatRecord(CLEARED, { clearedInto: CHAT_A }) + ] + const { path } = await seedProfile(records, { + newer, + visible: [CHAT_A, CHAT_B], + history: records.slice(0, 2) + }) + const bytes = await readFile(path) + const tabWrites = spyOnTabWrites() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // Writable when the store opens, then failing for good. + const { runtime, onError, published } = startupRuntime({ + afterInstall: () => { + writes.failing = writesFail + } + }) - await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() - expect(published().map((tab) => tab.id)).toEqual([ - `agent-session:${CHAT_A}`, - `agent-session:${CHAT_B}` - ]) - expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) - await expectHistory(CHAT_A) - await expectHistory(CHAT_B) - expect(onError).toHaveBeenCalledOnce() - expect(onError).toHaveBeenCalledWith({ - scope: 'structured-agent-session-lease-reconcile', - error: expect.objectContaining( - newer ? { message: 'agent_session_legacy_required' } : { code: 'ELOCKED' } - ) - }) - expect(writes.visibility).not.toHaveBeenCalled() - expect(writes.seed).not.toHaveBeenCalled() - await stopStructuredAgentSessionRuntime() - if (newer) { - expect(await readFile(path)).toEqual(bytes) - expect(await readdir(storeDirectory)).toEqual(files) + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${CHAT_A}`, + `agent-session:${CHAT_B}` + ]) + expect(published()[0]).toMatchObject({ replacesSessionId: CLEARED }) + await expectHistory(CHAT_A) + await expectHistory(CHAT_B) + // A newer Orca's leases are adjudicated in memory, so nothing fails there. + if (newer) { + expect(onError).not.toHaveBeenCalled() + } else { + expect(onError).toHaveBeenCalledOnce() + expect(onError).toHaveBeenCalledWith({ + scope: 'structured-agent-session-lease-reconcile', + error: expect.objectContaining({ message: 'disk I/O error' }) + }) + } + expect(tabWrites.visibility).not.toHaveBeenCalled() + expect(tabWrites.seed).not.toHaveBeenCalled() + await stopStructuredAgentSessionRuntime() + if (newer) { + expect(await readFile(path)).toEqual(bytes) + } } - }) + ) - it('costs two lock waits in all while the lock stays held, however many chats are open', async () => { + it('costs two failed writes in all while the store keeps failing, however many chats are open', async () => { const chats = Array.from({ length: 8 }, (_, index) => `chat-${index}-000${index}`) const records = chats.map((sessionId) => chatRecord(sessionId)) - await seedStore(records, { visible: chats }) - await seedHistory(records) + await seedProfile(records, { visible: chats }) vi.spyOn(console, 'warn').mockImplementation(() => undefined) const { runtime, published } = startupRuntime({ afterInstall: () => { - lock.failing = true + writes.failing = true } }) @@ -298,7 +314,7 @@ describe('restoring the chat tabs open at quit', () => { expect(published()).toHaveLength(8) // One for the startup reconcile, one for the restore's; the restore writes no tab. - expect(lock.refused).toBe(2) + expect(writes.refused).toBe(2) }) describe('on a legacy profile, with no tab index yet', () => { @@ -309,13 +325,12 @@ describe('restoring the chat tabs open at quit', () => { it('records every restored chat in one write', async () => { const records = legacyChats() - const { path } = await seedStore(records) - await seedHistory(records) - const writes = spyOnTabWrites() + await seedProfile(records) + const tabWrites = spyOnTabWrites() const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) await runtime.prepareStructuredAgentSessionStartupRestoration() - // One more take, then held: a seed written chat by chat would stop part way. - lock.grants = 1 + // One more write, then failing: a seed written chat by chat would stop part way. + writes.grants = 1 await runtime.restoreStructuredAgentSessionTabs() @@ -323,19 +338,18 @@ describe('restoring the chat tabs open at quit', () => { `agent-session:${CHAT_A}`, `agent-session:${CHAT_B}` ]) - expect(await tabIndexOnDisk(path)).toEqual([CHAT_A, CHAT_B]) - expect(writes.seed).toHaveBeenCalledOnce() - expect(writes.visibility).not.toHaveBeenCalled() + expect(await tabIndexOnDisk()).toEqual([CHAT_A, CHAT_B]) + expect(tabWrites.seed).toHaveBeenCalledOnce() + expect(tabWrites.visibility).not.toHaveBeenCalled() }) it('still lists the chats when that write fails, and leaves the index to seed again', async () => { const records = legacyChats() - const { path } = await seedStore(records) - await seedHistory(records) + await seedProfile(records) const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const { runtime, published } = startupRuntime({ profileChats: [CHAT_A, CHAT_B] }) await runtime.prepareStructuredAgentSessionStartupRestoration() - lock.failing = true + writes.failing = true await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() @@ -343,9 +357,12 @@ describe('restoring the chat tabs open at quit', () => { await expectHistory(CHAT_A) expect(warn).toHaveBeenCalledWith( '[structured-agent-session] recording restored chat tabs failed', - { sessionIds: [CHAT_A, CHAT_B], error: expect.objectContaining({ code: 'ELOCKED' }) } + { + sessionIds: [CHAT_A, CHAT_B], + error: expect.objectContaining({ message: 'disk I/O error' }) + } ) - expect(await tabIndexOnDisk(path)).toBeUndefined() + expect(await tabIndexOnDisk()).toBeUndefined() }) }) }) diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts index 2c05aa5004db..d5b2f2a08b3b 100644 --- a/src/shared/agent-session-next-fence.ts +++ b/src/shared/agent-session-next-fence.ts @@ -12,6 +12,8 @@ import type { AgentSessionLease } from './agent-session-record' -export function nextAgentSessionFence(lease: AgentSessionLease): number { +export function nextAgentSessionFence( + lease: Pick +): number { return Math.max(lease.runtimeFence + 1, lease.minimumNextFence ?? 0) } diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index 7474c751cbe0..5ca622eeac6d 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -299,6 +299,44 @@ describe('chain lookup and validation', () => { ]) ).toBe(false) }) + + it('validates a chain in work linear in its length', () => { + /** Property reads on every link of a valid chain of `length` resumes, counted per link. */ + const readsToValidate = (length: number): number[] => { + const reads = Array.from({ length }, () => 0) + const chain = Array.from( + { length }, + (_value, index) => + new Proxy( + link( + index === 0 + ? {} + : { + linkId: `link-${index + 1}`, + origin: 'resumed', + handle: { ...CLAUDE, leafUuid: `leaf-${index + 1}` } + } + ), + { + get: (target, property, receiver) => { + reads[index] += 1 + // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward. + return Reflect.get(target, property, receiver) + } + } + ) + ) + expect(isAgentSessionProviderHandleChain(chain)).toBe(true) + return reads + } + const half = readsToValidate(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS / 2) + const full = readsToValidate(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) + const total = (reads: number[]) => reads.reduce((sum, count) => sum + count, 0) + + // Each link is read a fixed number of times however long the chain is. + expect(Math.max(...full)).toBe(Math.max(...half)) + expect(total(full)).toBeLessThanOrEqual(2 * total(half) + Math.max(...full)) + }) }) describe('adopted chain heads', () => { diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index ea789dcdfe40..c3f339a7ce44 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -140,18 +140,20 @@ export function isAgentSessionProviderHandleChain( if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { return false } - let validated: AgentSessionProviderHandleLink[] = [] + const seenLinkIds = new Set() + let head: AgentSessionProviderHandleLink | null = null try { for (const link of value) { if (!isAgentSessionProviderHandleLink(link)) { return false } - const next = appendAgentSessionProviderHandleLink(validated, link) - // A persisted chain must name every link exactly once; retry elision belongs at append time. - if (next.length !== validated.length + 1) { + // A persisted chain must name every link exactly once; retry elision and supersession belong + // at append time. + if (agentSessionProviderHandleTransition(head, link, seenLinkIds) !== 'append') { return false } - validated = next + seenLinkIds.add(link.linkId) + head = link } return true } catch { @@ -170,12 +172,41 @@ export function appendAgentSessionProviderHandleLink( if (!isAgentSessionProviderHandleLink(link)) { throw new Error('agent_session_provider_handle_invalid') } - const head = agentSessionProviderHandleChainHead(chain) + const seenLinkIds = new Set(chain.map((existing) => existing.linkId)) + switch ( + agentSessionProviderHandleTransition( + agentSessionProviderHandleChainHead(chain), + link, + seenLinkIds + ) + ) { + case 'retry': + return [...chain] + case 'supersede': + // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. + return [link] + case 'append': + return [...chain, link] + } +} + +export type AgentSessionProviderHandleTransition = 'append' | 'retry' | 'supersede' + +/** + * What `link` does to a chain whose head is `head` and which already names `seenLinkIds`; throws + * when it is no legal successor. The one chain rule: appending and validating a persisted chain + * both go through it. `link` must already satisfy `isAgentSessionProviderHandleLink`. + */ +export function agentSessionProviderHandleTransition( + head: AgentSessionProviderHandleLink | null, + link: AgentSessionProviderHandleLink, + seenLinkIds: ReadonlySet +): AgentSessionProviderHandleTransition { if (!head) { if (link.origin !== 'created' && link.origin !== 'adopted') { throw new Error('agent_session_provider_handle_invalid') } - return [link] + return 'append' } if (link.handle.provider !== head.handle.provider) { throw new Error('agent_session_provider_handle_provider_mismatch') @@ -184,7 +215,8 @@ export function appendAgentSessionProviderHandleLink( throw new Error('agent_session_provider_handle_stale_fence') } if (link.origin === 'created' && link.supersedesKey !== undefined) { - return supersedeUnsavedCreation(head, link) + assertSupersedesUnsavedCreation(head, link) + return 'supersede' } if (link.origin === 'created' || link.origin === 'adopted') { throw new Error('agent_session_provider_handle_invalid') @@ -210,30 +242,30 @@ export function appendAgentSessionProviderHandleLink( link.mintedAtFence === head.mintedAtFence ) { // Why: re-proving the same handle at the same fence is a retry, not a new identity. - return [...chain] + return 'retry' } - if (findAgentSessionProviderHandleLink(chain, link.linkId)) { + if (seenLinkIds.has(link.linkId)) { // Why: the lease names its exact proof by link id; reuse would make that reference ambiguous. throw new Error('agent_session_provider_handle_invalid') } - if (chain.length >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + if (seenLinkIds.size >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { // Why: dropping older links would erase fork provenance, so refuse and let the caller roll // the journal epoch instead of silently losing where this conversation came from. throw new Error('agent_session_provider_handle_chain_overflow') } - return [...chain, link] + return 'append' } /** - * Replace the chain's only link, a creation the provider proved it never saved, with the creation - * that took its place. Every other head names a conversation the provider held (a resume or fork - * proved it, an adoption imported it), so only a `created` head can be superseded, and only by a - * new identity root that names it. + * Only the chain's only link, a creation the provider proved it never saved, can be replaced by + * the creation that took its place. Every other head names a conversation the provider held (a + * resume or fork proved it, an adoption imported it), so only a `created` head can be superseded, + * and only by a new identity root that names it. */ -function supersedeUnsavedCreation( +function assertSupersedesUnsavedCreation( head: AgentSessionProviderHandleLink, link: AgentSessionProviderHandleLink -): AgentSessionProviderHandleLink[] { +): void { if ( head.origin !== 'created' || link.supersedesKey !== agentSessionProviderHandleKey(head.handle) || @@ -242,6 +274,4 @@ function supersedeUnsavedCreation( ) { throw new Error('agent_session_provider_handle_invalid') } - // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. - return [link] } diff --git a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts deleted file mode 100644 index 96780a185176..000000000000 --- a/tests/e2e/cross-version-wire/agent-session-death-evidence-downgrade.unit.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, test } from 'vitest' -import { - agentSessionLeaseFixture, - agentSessionRecordFixture -} from '../../../src/shared/agent-session-record.test-fixture' -import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' -import { agentSessionStorePath } from '../../../src/main/runtime/agent-session-record-store-file' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -// The last release before a proof of death named its owner and its last proof of life. -const BASELINE_REF = 'v1.4.211' -const SESSION = 'session-alpha-1' - -test('an older build loads a proof of death that names its owner and its last proof of life', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-death-evidence-downgrade-')) - try { - // A live owner at fence 7, last renewed at 30000, found gone when this build relaunches. - writeFileSync( - agentSessionStorePath(directory), - JSON.stringify({ - schemaVersion: 2, - hostId: 'local', - records: { [SESSION]: agentSessionRecordFixture(agentSessionLeaseFixture()) }, - operations: {}, - retiredClaimKeys: [], - unusableRecords: {} - }) - ) - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - await store.reconcileOnRestart({ probe: async () => ({ outcome: 'pid-absent' }), now: 90_000 }) - const evidence = store.getRecord(SESSION)?.lease.deathEvidence - expect(evidence).toEqual({ - kind: 'pid-absent', - detail: 'recorded pid absent on host', - observedAt: 90_000, - ownerFence: 7, - lastProvenAliveAt: 30_000 - }) - - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const baseline = await importReleaseCheckoutModule( - checkout, - 'src/main/runtime/agent-session-record-store.ts' - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read members called below; a missing one fails the test. - const OldStore = baseline.AgentSessionRecordStore as { - open: (args: { directory: string; hostId: string }) => Promise - } - const old = await OldStore.open({ directory, hostId: 'local' }) - expect(old.isSessionUnreadable(SESSION)).toBe(false) - expect(old.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - deathEvidence: evidence - }) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -}) diff --git a/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts b/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts deleted file mode 100644 index 80626f79ae31..000000000000 --- a/tests/e2e/cross-version-wire/agent-session-unproven-release-downgrade.unit.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { expect, test } from 'vitest' -import { resolveStructuredSessionRecovery } from '../../../src/main/native-chat/agent-session-wire/structured-agent-session-recovery-resolution' -import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session-record-store' -import { importReleaseCheckoutModule, materializeReleaseCheckout } from './release-checkout' - -// The last release before recovery released an owner it could not prove gone. -const BASELINE_REF = 'v1.4.211' -const SESSION = 'session-unproven' -const NOW = 1_800_000_000_000 -const LOCATION = { - executionHostId: 'local', - wslDistro: null, - workspaceId: 'workspace-1', - workspaceKind: 'folder' as const -} - -function reserveRequest(expectedFence: number | null, spawnToken: string, operation: number) { - return { - sessionId: SESSION, - location: LOCATION, - provider: 'codex' as const, - accountHome: { variable: 'CODEX_HOME' as const, path: '/tmp/codex' }, - // Older builds still read the owner kind from the request. - runtimeKind: 'native' as const, - expectedFence, - spawnToken, - claimKeyId: 'key-1', - handoffOperationId: null, - probe: { outcome: 'reservation-unused' as const }, - operation: { - callerKey: 'test', - operationId: `${NOW}-${String(operation).padStart(32, '0')}`, - fingerprint: 'create' - }, - now: NOW - } -} - -test('an older build loads, and starts over, a lease released with no death evidence', async () => { - const directory = mkdtempSync(join(tmpdir(), 'orca-unproven-release-downgrade-')) - try { - // This build: an owner whose identity can never be verified is released, with no evidence. - const store = await AgentSessionRecordStore.open({ directory, hostId: 'local' }) - const reserved = await store.reserveOwner(reserveRequest(null, 'spawn-a', 1)) - const fence = reserved.record.lease.runtimeFence - await store.commitProcessIdentity({ - sessionId: SESSION, - fence, - process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken: 'spawn-a' }, - now: NOW - }) - await store.transitionHandoff(SESSION, (record) => ({ - ...record, - lease: { ...record.lease, handoffStage: 'recovering' } - })) - await expect( - resolveStructuredSessionRecovery( - { - store, - probeRecord: async () => ({ outcome: 'indeterminate', reason: 'no start time' }), - now: () => NOW - }, - SESSION - ) - ).resolves.toBe('resolved') - expect(store.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - deathEvidence: null - }) - - // The older build reads exactly what this one wrote. - const checkout = await materializeReleaseCheckout(BASELINE_REF) - const baseline = await importReleaseCheckoutModule( - checkout, - 'src/main/runtime/agent-session-record-store.ts' - ) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release exports this class with the open/read/reconcile/reserve members called below; a missing one fails the test. - const OldStore = baseline.AgentSessionRecordStore as { - open: (args: { directory: string; hostId: string }) => Promise - } - const old = await OldStore.open({ directory, hostId: 'local' }) - expect(old.isSessionUnreadable(SESSION)).toBe(false) - expect(old.getRecord(SESSION)?.lease).toMatchObject({ - claimStatus: 'released', - handoffStage: null, - ownerProcess: null, - deathEvidence: null - }) - await old.reconcileOnRestart({ - probe: async () => ({ outcome: 'indeterminate', reason: 'no owner to probe' }), - now: NOW + 1 - }) - const released = old.getRecord(SESSION)?.lease - expect(released).toMatchObject({ claimStatus: 'released', handoffStage: null }) - // And it can start a new owner over it. - const restarted = await old.reserveOwner( - reserveRequest(released?.runtimeFence ?? null, 'spawn-b', 2) - ) - expect(restarted.record.lease).toMatchObject({ claimStatus: 'reserved' }) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -}) From a93322012517e879d74bd7176def7c6528e11a56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:55:28 -0700 Subject: [PATCH 09/30] wip: importer tests --- ...agent-session-legacy-record-import.test.ts | 386 ++++++++++++++++++ 1 file changed, 386 insertions(+) create mode 100644 src/main/runtime/agent-session-legacy-record-import.test.ts diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts new file mode 100644 index 000000000000..f8d5f82cfa2c --- /dev/null +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -0,0 +1,386 @@ +// 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 { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' +import { + agentSessionOperationKey, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' +import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record' +import { + agentSessionLeaseFixture, + agentSessionRecordFixture +} from '../../shared/agent-session-record.test-fixture' +import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { LegacyAgentSessionRecordImportReport } from './agent-session-legacy-record-import' +import { AgentSessionRecordStore } from './agent-session-record-store' +import { agentSessionStorePath } from './agent-session-record-store-file' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' + +const NOW = 1_800_000_000_000 +const ALPHA = 'session-alpha' +const BETA = 'session-beta' +const OPERATION_ID = `${NOW}-000000000000000000000000000000aa` +const LEGACY_TAB_ID = `structured-agent-session-${ALPHA}` + +let root: string +const opened: JournalHostDatabase[] = [] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-legacy-record-import-')) +}) + +afterEach(async () => { + opened.splice(0).forEach((database) => database.close()) + await rm(root, { recursive: true, force: true }) +}) + +const legacyPath = (): string => agentSessionStorePath(join(root, 'agent-sessions')) + +/** A released chat: no owner a restart probe must look for. */ +function record(sessionId: string, lease: Partial = {}): AgentSessionRecord { + return agentSessionRecordFixture( + agentSessionLeaseFixture({ + sessionId, + runtimeFence: 2, + ownerProcess: null, + reservedSpawnToken: null, + claimStatus: 'released', + ...lease + }) + ) +} + +const OPERATION: AgentSessionOperationRow = { + callerKey: 'client-1', + operationId: OPERATION_ID, + fingerprint: 'fp-1', + operationTimestamp: NOW, + recordedAt: NOW, + expiresAt: NOW + 60_000, + outcome: { status: 'succeeded', sessionId: ALPHA } +} + +function legacyFile( + records: readonly unknown[], + extra: Record = {} +): Record { + return { + schemaVersion: 2, + hostId: 'local', + records: Object.fromEntries( + records.map((value) => [(value as { sessionId: string }).sessionId, value]) + ), + operations: {}, + retiredClaimKeys: [], + unusableRecords: {}, + ...extra + } +} + +async function writeLegacy(file: unknown, backup?: unknown): Promise { + await mkdir(join(root, 'agent-sessions'), { recursive: true }) + await writeFile(legacyPath(), typeof file === 'string' ? file : JSON.stringify(file)) + if (backup !== undefined) { + await writeFile( + `${legacyPath()}.bak`, + typeof backup === 'string' ? backup : JSON.stringify(backup) + ) + } +} + +/** What an install does: open the database, running the copy if it is owed, then the store. */ +async function install(): Promise<{ + database: JournalHostDatabase + store: AgentSessionRecordStore + reports: LegacyAgentSessionRecordImportReport[] +}> { + const reports: LegacyAgentSessionRecordImportReport[] = [] + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + legacyRecordsDirectory: join(root, 'agent-sessions'), + hostId: 'local', + onLegacyRecordImportReport: (report) => reports.push(report) + }) + opened.push(database) + return { database, store: AgentSessionRecordStore.open({ journalDatabase: database, hostId: 'local' }), reports } +} + +function unreconciled(value: AgentSessionRecord): AgentSessionRecord { + return { ...value, lease: { ...value.lease, unreconciled: true } } +} + +describe('copying the records file into the chat database', () => { + it('copies every record, operation, key and tab, and leaves both files byte-identical', async () => { + const alpha = record(ALPHA) + const beta = record(BETA, { runtimeFence: 5 }) + const file = legacyFile([alpha, beta], { + operations: { [agentSessionOperationKey('client-1', OPERATION_ID)]: OPERATION }, + retiredClaimKeys: [{ keyId: 'key-0', retiredAt: NOW }], + sessionTabs: [{ tabId: 'tab-beta', sessionId: BETA }] + }) + await writeLegacy(file, file) + const [primary, backup] = await Promise.all([ + readFile(legacyPath()), + readFile(`${legacyPath()}.bak`) + ]) + + const { database, store, reports } = await install() + + expect(reports).toEqual([]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.getRecord(ALPHA)).toEqual(unreconciled(alpha)) + expect(store.getRecord(BETA)).toEqual(unreconciled(beta)) + expect(store.listOperationRows()).toEqual([OPERATION]) + expect(store.isClaimKeyVerifiable('key-0', NOW)).toBe(true) + expect(store.getVisibleSessionTabIndex()).toEqual({ present: true, sessionIds: [BETA] }) + expect(store.getSessionTabId(BETA)).toBe('tab-beta') + expect(await readFile(legacyPath())).toEqual(primary) + expect(await readFile(`${legacyPath()}.bak`)).toEqual(backup) + }) + + it('copies once: a later launch never reads the file again', async () => { + await writeLegacy(legacyFile([record(ALPHA)])) + const first = await install() + await first.store.setConversationName(ALPHA, 'kept') + first.database.close() + // A downgraded build changed its own file; the database keeps what it holds. + await writeLegacy(legacyFile([record(BETA)])) + + const { store } = await install() + + expect(store.getRecord(ALPHA)?.conversationName).toBe('kept') + expect(store.getRecord(BETA)).toBeNull() + }) + + it('reports a file no read will make usable, copies nothing, and leaves it for recovery', async () => { + const adHoc = JSON.stringify({ ...legacyFile([record(ALPHA)]), schemaVersion: 1 }) + await writeLegacy(adHoc) + + const { database, store, reports } = await install() + + expect(reports).toEqual([ + { kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) } + ]) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.listRecords()).toEqual([]) + expect(await readFile(legacyPath(), 'utf-8')).toBe(adHoc) + }) + + it('reports a file from another host id and copies it, so its owners stay unresolved', async () => { + await writeLegacy({ ...legacyFile([record(ALPHA)]), hostId: 'ssh:elsewhere' }) + + const { store, reports } = await install() + + expect(reports).toEqual([{ kind: 'host-mismatch', fileHostId: 'ssh:elsewhere', hostId: 'local' }]) + expect(store.hostId).toBe('local') + expect(store.getRecord(ALPHA)).not.toBeNull() + }) + + it('maps a lease the removed terminal handoff wrote, and stores it mapped', async () => { + const legacy = { + ...record(ALPHA, { ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' } }), + lease: { + ...record(ALPHA).lease, + ownerProcess: { hostId: 'local', pid: 4242, processStartTimeMs: 1, spawnToken: 'spawn-a' }, + claimStatus: 'live', + runtimeKind: 'tui', + handoffStage: 'old-owner-stopped', + handoffOperationId: 'op-handoff', + processlessAt: 5 + } + } + await writeLegacy(legacyFile([legacy])) + + const { database, store } = await install() + + expect(store.isSessionUnreadable(ALPHA)).toBe(false) + const lease = store.getRecord(ALPHA)?.lease + expect(lease).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering', + handoffOperationId: 'op-handoff', + claimStatus: 'conflicted' + }) + expect(lease).not.toHaveProperty('processlessAt') + const stored = database.db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(ALPHA) + expect(JSON.parse(String(stored?.record_json)).lease).toMatchObject({ + runtimeKind: 'native', + handoffStage: 'recovering' + }) + expect(JSON.parse(String(stored?.record_json)).lease).not.toHaveProperty('processlessAt') + }) +}) + +describe('a record the file set aside', () => { + it('copies a record this build cannot read verbatim, and never grants it', async () => { + const unsupported = { ...record(ALPHA), schemaVersion: 1 } + await writeLegacy(legacyFile([unsupported])) + + const { database, store } = await install() + + expect(store.getRecord(ALPHA)).toBeNull() + expect(store.isSessionUnreadable(ALPHA)).toBe(true) + await expect(store.setConversationName(ALPHA, 'x')).rejects.toThrow( + 'execution_owner_reconciling' + ) + const stored = database.db + .prepare('SELECT record_json FROM agent_session_records WHERE session_id = ?') + .get(ALPHA) + expect(JSON.parse(String(stored?.record_json))).toEqual(unsupported) + }) + + // #23589: both copies exist only when an older build recovered the readable one from its backup, + // so the set-aside copy may already have granted fences the readable one cannot show. + it('keeps the readable copy and raises its floor above both: 8, never 3, never 7 twice', async () => { + const readable = record(ALPHA, { runtimeFence: 2 }) + const setAside = { ...record(ALPHA, { runtimeFence: 7 }), schemaVersion: 99 } + await writeLegacy( + legacyFile([readable], { + unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: setAside } } + }) + ) + + const { store } = await install() + + const lease = store.getRecord(ALPHA)?.lease + expect(lease?.runtimeFence).toBe(2) + expect(lease?.minimumNextFence).toBe(8) + expect(nextAgentSessionFence(lease!)).toBe(8) + }) + + it('takes a record the primary set aside from the backup, with its floor raised', async () => { + const readable = record(ALPHA, { runtimeFence: 3 }) + await writeLegacy( + legacyFile([], { + unusableRecords: { [ALPHA]: { reason: 'unsupported_schema', raw: { schemaVersion: 99 } } } + }), + legacyFile([readable]) + ) + + const { store } = await install() + + // The commit the backup lost may have granted 4, so the next grant clears it. + expect(store.getRecord(ALPHA)?.lease.minimumNextFence).toBe(5) + }) +}) + +describe('a primary the backup stands in for', () => { + it('copies the backup with every floor raised past what the lost commit could have granted', async () => { + await writeLegacy('{ truncated', legacyFile([record(ALPHA, { runtimeFence: 1 })])) + + const { store, reports } = await install() + + expect(reports).toEqual([]) + expect(store.getRecord(ALPHA)?.lease).toMatchObject({ runtimeFence: 1, minimumNextFence: 3 }) + expect(await readFile(legacyPath(), 'utf-8')).toBe('{ truncated') + }) + + it('reports both copies unusable and copies nothing', async () => { + await writeLegacy('{ truncated', '{ also truncated') + + const { store, reports } = await install() + + expect(reports).toMatchObject([{ kind: 'unusable' }]) + expect(store.listRecords()).toEqual([]) + }) +}) + +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('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) + }) +}) From 1ba05a606e93cfd68a06ec7455cde441964b32e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:04:08 -0700 Subject: [PATCH 10/30] test(native-chat): cover the records migration, the import, row writes and read-only records --- ...journal-database-records-migration.test.ts | 219 ++++++++++++++++++ ...-session-startup-reconcile-failure.test.ts | 18 ++ .../agent-session-store-transactions.test.ts | 201 ++++++++++++++++ .../agent-session-store-transactions.ts | 14 +- ...gent-session-record-import-install.test.ts | 171 ++++++++++++++ ...ed-agent-session-startup-reconcile.test.ts | 25 ++ 6 files changed, 636 insertions(+), 12 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts create mode 100644 src/main/runtime/agent-session-store-transactions.test.ts create mode 100644 src/main/runtime/structured-agent-session-record-import-install.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts new file mode 100644 index 000000000000..3286e5070b29 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts @@ -0,0 +1,219 @@ +// 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 { 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, + type JournalLegacyRecordImport +} from './journal-database' +import { createJournalTablesSql } from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' + +let root: string +let dbPath: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-records-migration-')) + dbPath = journalDatabasePath(root) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +/** What a build from before the records moved left: the released version-3 shape. */ +function seedVersion3(): void { + const db = new Database(dbPath) + db.pragma('journal_mode = WAL') + db.exec(createJournalTablesSql()) + db.prepare( + "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s1', 'ws', 'e1')" + ).run() + db.pragma('user_version = 3') + db.close() +} + +/** A copy of one record row, counting how many times the migration ran it. */ +function importOf(sessionId: string): JournalLegacyRecordImport & { runs: number } { + const copy = { + owed: false as const, + runs: 0, + write: (db: Database.Database) => { + copy.runs += 1 + db.prepare( + 'INSERT OR IGNORE INTO agent_session_records (session_id, record_json) VALUES (?, ?)' + ).run(sessionId, '{}') + } + } + return copy +} + +function inspect(read: (db: Database.Database) => T): T { + const db = new Database(dbPath) + try { + return read(db) + } finally { + db.close() + } +} + +const recordIds = (db: Database.Database): string[] => + db + .prepare('SELECT session_id FROM agent_session_records ORDER BY session_id') + .all() + .map((row) => String(row.session_id)) + +const hasTable = (db: Database.Database, name: string): boolean => + db + .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?") + .get(name) !== undefined + +describe('the version-4 migration', () => { + it('moves a version-3 database to 4 with its rows and the copied records', () => { + seedVersion3() + const copy = importOf('session-a') + + const opened = openJournalDatabase(dbPath, copy) + opened.db.close() + + expect(opened).toMatchObject({ readOnly: false, legacyRecordImportOwed: false }) + expect(copy.runs).toBe(1) + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['session-a']) + expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toEqual({ epoch: 'e1' }) + }) + }) + + it('never runs the copy again once the database is at 4', () => { + seedVersion3() + openJournalDatabase(dbPath, importOf('session-a')).db.close() + const again = importOf('session-b') + + openJournalDatabase(dbPath, again).db.close() + + expect(again.runs).toBe(0) + inspect((db) => expect(recordIds(db)).toEqual(['session-a'])) + }) + + it('leaves version 3 and no record rows when it dies inside the migration, then copies once', () => { + seedVersion3() + const original = Database.prototype.pragma + const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( + this: Database.Database, + sql: string, + options?: { simple?: boolean } + ) { + if (sql === 'user_version = 4') { + throw new Error('crash before the version is published') + } + return original.call(this, sql, options) + }) + + expect(() => openJournalDatabase(dbPath, importOf('session-a'))).toThrow( + 'crash before the version is published' + ) + pragma.mockRestore() + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(3) + expect(hasTable(db, 'agent_session_records')).toBe(false) + }) + + const retry = importOf('session-a') + openJournalDatabase(dbPath, retry).db.close() + expect(retry.runs).toBe(1) + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['session-a']) + }) + }) + + it('makes the tables but keeps the copy owed when the records file could not be read', () => { + seedVersion3() + + const opened = openJournalDatabase(dbPath, { owed: true }) + // A chat created while the copy is owed keeps its row through the copy that follows. + opened.db + .prepare("INSERT INTO agent_session_records (session_id, record_json) VALUES ('new', '{}')") + .run() + opened.db.close() + + expect(opened.legacyRecordImportOwed).toBe(true) + inspect((db) => expect(journalPragmaNumber(db, 'user_version')).toBe(3)) + openJournalDatabase(dbPath, importOf('session-a')).db.close() + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + expect(recordIds(db)).toEqual(['new', 'session-a']) + }) + }) + + it('stamps a fresh file with the released version while the copy is owed', () => { + openJournalDatabase(dbPath, { owed: true }).db.close() + + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(3) + expect(hasTable(db, 'journal_rows')).toBe(true) + expect(hasTable(db, 'agent_session_records')).toBe(true) + }) + }) + + it('creates every table on a fresh file at version 4', () => { + openJournalDatabase(dbPath, NO_LEGACY_JOURNAL_RECORDS).db.close() + + inspect((db) => { + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + for (const table of [ + 'journal_rows', + 'agent_session_records', + 'agent_session_operations', + 'agent_session_retired_claim_keys', + 'agent_session_tabs', + 'agent_session_store_meta' + ]) { + expect(hasTable(db, table)).toBe(true) + } + }) + }) +}) + +/** + * The open of a build released before the records moved (version 3), pinned here: it latches any + * higher version read-only. Its records file stays its own and writable, so nothing it does can + * reach the records this database holds. + */ +function openAsVersion3Build(path: string): { db: Database.Database; readOnly: boolean } { + const probe = new Database(path) + const stored = journalPragmaNumber(probe, 'user_version') + if (stored > 3) { + probe.close() + return { db: new Database(path, { readonly: true, fileMustExist: true }), readOnly: true } + } + return { db: probe, readOnly: false } +} + +describe('a build from before the move', () => { + it('opens a version-4 database read-only, so it never writes beside the records', () => { + openJournalDatabase(dbPath, importOf('session-a')).db.close() + + const older = openAsVersion3Build(dbPath) + try { + expect(older.readOnly).toBe(true) + expect(() => + older.db + .prepare( + "INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES ('s2', 'ws', 'e')" + ) + .run() + ).toThrow(/readonly/i) + } finally { + older.db.close() + } + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index bea2c0be26ef..bc6294d018be 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -11,6 +11,7 @@ import { seedTestAgentSessionStoreFromNewerBuild } from '../../runtime/agent-session-record-store-test-harness' import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' import { StructuredAgentSessionHost, type StructuredAgentSessionHostDeps @@ -163,6 +164,23 @@ it('restores a chat for reading while the reconcile keeps failing, and reports i expect(onLeaseReconcileFailure).toHaveBeenCalledWith(IO_ERROR) }) +it('refuses a send over records a newer Orca wrote with the update words', async () => { + const { host } = await relaunch(true) + await host.reconcileRestartLeases() + + const body = hostTestMessage('sent to a chat a newer Orca saved') + await expect( + host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + ).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } + }) +}) + it('restores a chat for reading from records a newer Orca wrote', async () => { const { host, stateDirectory, onLeaseReconcileFailure } = await relaunch(true) const path = journalDatabasePath(stateDirectory) diff --git a/src/main/runtime/agent-session-store-transactions.test.ts b/src/main/runtime/agent-session-store-transactions.test.ts new file mode 100644 index 000000000000..5ab6cad9e028 --- /dev/null +++ b/src/main/runtime/agent-session-store-transactions.test.ts @@ -0,0 +1,201 @@ +// How a record-store write reaches the chat database: only the rows it changed, each checked with +// the rules a load applies, never inside a caller's own transaction, and never in memory unless the +// rows committed. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionRecord } from '../../shared/agent-session-record' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../native-chat/agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from './agent-session-record-store' +import { + openTestAgentSessionRecordStore, + readPersistedTestAgentSessionStore +} from './agent-session-record-store-test-harness' + +const NOW = 1_800_000_000_000 + +let root: string +let counter = 0 + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-agent-session-store-transactions-')) +}) + +afterEach(async () => { + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +/** Reserve, observe the spawn, prove the handle: a chat with a live writer. */ +async function liveChat( + store: AgentSessionRecordStore, + sessionId: string +): Promise { + counter += 1 + const reserved = await store.reserveOwner({ + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'git-worktree' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: `spawn-${counter}`, + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${String(counter).padStart(32, '0')}`, + fingerprint: `fp-${counter}` + }, + now: NOW + }) + const fence = reserved.record.lease.runtimeFence + await store.commitProcessIdentity({ + sessionId, + fence, + process: { + hostId: 'local', + pid: 4000 + counter, + processStartTimeMs: NOW, + spawnToken: `spawn-${counter}` + }, + now: NOW + }) + return store.proveOwner({ + sessionId, + fence, + link: { + linkId: `link-${counter}`, + handle: { provider: 'claude', sessionId: `provider-${counter}`, leafUuid: null }, + origin: 'created', + mintedAtFence: fence, + observedAt: NOW + }, + now: NOW + }) +} + +function totalChanges(): number { + return Number( + openTestJournalHostDatabase(root).db.prepare('SELECT total_changes() AS n').get()?.n ?? 0 + ) +} + +describe('writing a transaction', () => { + it('writes one record row for a renewal of one chat among several', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + await liveChat(store, 'chat-b-0002') + const renewed = await liveChat(store, 'chat-c-0003') + const before = totalChanges() + + await store.renewLease({ + sessionId: 'chat-c-0003', + fence: renewed.lease.runtimeFence, + childProbe: { outcome: 'identity-matched', matchedOn: ['spawn-token'] }, + now: NOW + 10_000 + }) + + expect(totalChanges() - before).toBe(1) + expect( + (await openTestAgentSessionRecordStore(root)).getRecord('chat-c-0003')?.lease.lastRenewedAt + ).toBe(NOW + 10_000) + }) + + it('writes nothing for a transaction that changes nothing', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const before = totalChanges() + + await store.renewLeases([]) + + expect(totalChanges()).toBe(before) + }) + + // Every mutation lands after the caller's frame, so one made inside an open journal transaction + // on the same connection never nests a BEGIN there. + it('runs a store call made inside an open journal transaction after that transaction', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + let pending: Promise | null = null + + openTestJournalHostDatabase(root).transaction(() => { + pending = store.setConversationName('chat-a-0001', 'renamed') + }) + + await expect(pending).resolves.toMatchObject({ conversationName: 'renamed' }) + expect((await readPersistedTestAgentSessionStore(root)).records['chat-a-0001']).toMatchObject({ + conversationName: 'renamed' + }) + }) +}) + +describe('a change a load would refuse', () => { + it('rejects a tab id that could not prefix a pane key, and keeps memory and rows as they were', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const persisted = await readPersistedTestAgentSessionStore(root) + + await expect( + store.setSessionTabVisibility('chat-a-0001', true, 'agent-session:chat-a-0001') + ).rejects.toThrow('agent_session_store_write_invalid') + + expect(store.getSessionTabId('chat-a-0001')).toBeNull() + expect(store.getVisibleSessionTabIndex().present).toBe(false) + expect(await readPersistedTestAgentSessionStore(root)).toEqual(persisted) + }) + + it('rejects a record whose handle chain names a link twice', async () => { + const store = await openTestAgentSessionRecordStore(root) + const before = await liveChat(store, 'chat-a-0001') + + await expect( + store.transitionHandoff('chat-a-0001', (record) => ({ + ...record, + providerHandleChain: [...record.providerHandleChain, ...record.providerHandleChain] + })) + ).rejects.toThrow('agent_session_store_write_invalid') + + expect(store.getRecord('chat-a-0001')).toBe(before) + expect( + (await openTestAgentSessionRecordStore(root)).getRecord('chat-a-0001')?.providerHandleChain + ).toHaveLength(1) + }) +}) + +describe('rows in memory', () => { + it('throws on a change made in place, which the row diff would never write', async () => { + const store = await openTestAgentSessionRecordStore(root) + await liveChat(store, 'chat-a-0001') + const loaded = (await openTestAgentSessionRecordStore(root)).getRecord('chat-a-0001') + + expect(() => Object.assign(loaded?.lease ?? {}, { runtimeFence: 99 })).toThrow(TypeError) + expect(() => + Object.assign(store.getRecord('chat-a-0001') ?? {}, { conversationName: 'x' }) + ).toThrow(TypeError) + }) +}) + +describe('after the database closes', () => { + // Late settlement at quit lands here: the caller reports it, and nothing in memory moves. + it('refuses a write with journal_closed and leaves memory as it was', async () => { + const store = await openTestAgentSessionRecordStore(root) + const before = await liveChat(store, 'chat-a-0001') + closeTestJournalHostDatabases() + + await expect(store.setConversationName('chat-a-0001', 'late')).rejects.toMatchObject({ + code: 'journal_closed' + }) + expect(store.getRecord('chat-a-0001')).toBe(before) + }) +}) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index b4b57f30d89a..3ec3e7ca4822 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -9,10 +9,7 @@ import type Database from '../sqlite/sync-database' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { - classifyJournalOpenFailure, - journalOpenRefusalError -} from '../native-chat/agent-session-journal/journal-open-failure' +import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-record-store-file' import { writeAgentSessionStoreRows } from './agent-session-record-rows' @@ -132,14 +129,7 @@ export class AgentSessionStoreTransactions { const staged = this.stage(apply) const writes = staged.writes if (writes && !readOnly) { - try { - this.journalDatabase.transaction((db) => writeAgentSessionStoreRows(db, writes)) - } catch (error) { - // Damage SQLite proves is the chat database's: the refusal a journal write gives for it. - throw classifyJournalOpenFailure(error) === 'journalCorrupt' - ? journalOpenRefusalError(error) - : error - } + this.journalDatabase.transaction((db) => writeAgentSessionStoreRows(db, writes)) } staged.adopt() return staged.result diff --git a/src/main/runtime/structured-agent-session-record-import-install.test.ts b/src/main/runtime/structured-agent-session-record-import-install.test.ts new file mode 100644 index 000000000000..551fab477c41 --- /dev/null +++ b/src/main/runtime/structured-agent-session-record-import-install.test.ts @@ -0,0 +1,171 @@ +// The records file's one-time copy never keeps the host from installing. A file no read will make +// usable is reported and left alone; a read that can clear leaves the copy owed, and until a later +// launch makes it, the chat list says it cannot tell rather than "none". + +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { agentSessionRecordFixture } from '../../shared/agent-session-record.test-fixture' +import Database from '../sqlite/sync-database' +import { journalPragmaNumber } from '../native-chat/agent-session-journal/journal-database' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' +import { OrcaRuntimeService } from './orca-runtime' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const NOW = 1_800_000_000_000 +const IMPORTED = 'session-alpha-1' +const CREATED_WHILE_OWED = 'session-new-0001' + +let root: string + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-record-import-install-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await chmod(legacyAgentSessionStorePath(root), 0o600).catch(() => undefined) + await rm(root, { recursive: true, force: true }) +}) + +async function writeLegacy(contents: string): Promise { + await mkdir(dirname(legacyAgentSessionStorePath(root)), { recursive: true }) + await writeFile(legacyAgentSessionStorePath(root), contents) +} + +const legacyFile = (): string => + JSON.stringify({ + schemaVersion: 2, + hostId: 'local', + records: { [IMPORTED]: agentSessionRecordFixture() }, + operations: {}, + retiredClaimKeys: [], + unusableRecords: {} + }) + +function databaseVersion(): number { + const db = new Database(journalDatabasePath(root)) + try { + return journalPragmaNumber(db, 'user_version') + } finally { + db.close() + } +} + +/** The runtime at startup, rooted at `root`, with its PTY daemon stubbed. */ +function startupRuntime(onError: (input: { scope: string; error: unknown }) => void) { + const runtime = new OrcaRuntimeService() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + structuredAgentSessionInventoryUnverifiable: boolean + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = () => + ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }), + onError + }) + internal.refreshMobileSessionPtyRecords = async () => new Set() + return { + runtime, + host: () => internal.ensureStructuredAgentSessionHost(), + unverifiable: () => internal.structuredAgentSessionInventoryUnverifiable + } +} + +function importReports(onError: ReturnType): unknown[] { + return onError.mock.calls + .map(([input]) => input) + .filter((input) => input.scope === 'structured-agent-session-record-import') + .map((input) => input.error) +} + +// A read that can clear: the file's own permissions, as a locked-down or mid-restore profile has. +it.skipIf(process.platform === 'win32' || process.getuid?.() === 0)( + 'installs while the file cannot be read, says it cannot tell, and copies it once it can', + async () => { + await writeLegacy(legacyFile()) + await chmod(legacyAgentSessionStorePath(root), 0o000) + const onError = vi.fn() + const first = startupRuntime(onError) + + await first.runtime.restoreStructuredAgentSessionTabs() + const host = await first.host() + + expect(importReports(onError)).toEqual([ + { + kind: 'unavailable', + error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) + } + ]) + expect(host.legacyRecordImportOwed()).toBe(true) + expect(first.unverifiable()).toBe(true) + expect(databaseVersion()).toBe(3) + // A chat started meanwhile lives in the database and keeps its row through the copy. + await host.deps.store.reserveOwner({ + sessionId: CREATED_WHILE_OWED, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: 'workspace-1', + workspaceKind: 'folder' + }, + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' }, + expectedFence: null, + spawnToken: 'spawn-new', + claimKeyId: 'key-1', + handoffOperationId: null, + probe: { outcome: 'reservation-unused' }, + operation: { + callerKey: 'client-1', + operationId: `${NOW}-${'0'.repeat(31)}1`, + fingerprint: 'fp-new' + }, + now: NOW + }) + await stopStructuredAgentSessionRuntime() + + await chmod(legacyAgentSessionStorePath(root), 0o600) + const second = startupRuntime(vi.fn()) + await second.runtime.restoreStructuredAgentSessionTabs() + const relaunched = await second.host() + + expect(relaunched.legacyRecordImportOwed()).toBe(false) + expect(second.unverifiable()).toBe(false) + expect(databaseVersion()).toBe(4) + expect(relaunched.deps.store.getRecord(IMPORTED)).not.toBeNull() + expect(relaunched.deps.store.getRecord(CREATED_WHILE_OWED)).not.toBeNull() + } +) + +it('installs over a file no read will make usable, reports it once, and leaves it untouched', async () => { + await writeLegacy('{ truncated') + const onError = vi.fn() + const { runtime, host } = startupRuntime(onError) + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + + expect(importReports(onError)).toEqual([ + { kind: 'unusable', error: expect.objectContaining({ message: 'agent_session_store_corrupt' }) } + ]) + expect((await host()).legacyRecordImportOwed()).toBe(false) + expect(databaseVersion()).toBe(4) + await stopStructuredAgentSessionRuntime() + expect(await readFile(legacyAgentSessionStorePath(root), 'utf-8')).toBe('{ truncated') +}) diff --git a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts index 71f0d28e6488..67a7d6b080de 100644 --- a/src/main/runtime/structured-agent-session-startup-reconcile.test.ts +++ b/src/main/runtime/structured-agent-session-startup-reconcile.test.ts @@ -134,3 +134,28 @@ it('logs the failure when the host has no error sink', async () => { expect.objectContaining({ message: 'disk I/O error' }) ) }) + +// Closing a chat tab drops it from the restore index, which a newer Orca's records refuse: the +// close is reported and goes on, since bookkeeping never keeps a tab open. +it('closes a chat tab over records a newer Orca wrote, reporting the index it cannot write', async () => { + const { sessionId } = await seedChat({ newer: true }) + const runtime = startupRuntime(vi.fn()) + await runtime.prepareStructuredAgentSessionStartupRestoration() + const host = getStructuredAgentSessionHost() + const close = vi.spyOn(host!, 'close') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected close path, reached with the one field it reads. + const internal = runtime as unknown as { + closeStructuredAgentSessionTab(tab: { sessionId: string }): Promise + } + + await expect(internal.closeStructuredAgentSessionTab({ sessionId })).resolves.toBeUndefined() + + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording a closed chat tab failed', + expect.objectContaining({ + refusal: expect.objectContaining({ details: { reason: 'journalWrittenByNewerOrca' } }) + }) + ) + expect(close).toHaveBeenCalledWith(sessionId) +}) From fa91b3718c175be62ad95a61c5a715d4d1add31b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:08:52 -0700 Subject: [PATCH 11/30] docs(native-chat): retire comments that describe the records file as the live store --- .../structured-agent-session-restart-reconcile.ts | 2 +- .../agent-session-backup-recovery-fence.ts | 13 ++++++------- .../agent-session-legacy-record-import.test.ts | 6 ++---- .../rpc/methods/agent-launch-replay.test.ts | 11 ++++------- src/shared/agent-session-next-fence.ts | 9 +++++---- src/shared/agent-session-operation-ledger.ts | 15 ++++++--------- src/shared/agent-session-record.ts | 4 ++-- 7 files changed, 26 insertions(+), 34 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index be2d0b6c1513..b5e352f3441c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -11,7 +11,7 @@ import { classifyStoreFailure } from './structured-agent-session-attach' const MAX_RECONCILIATION_PASSES = 8 -/** Adjudicates leases loaded by this process or refreshed from another writer. +/** Adjudicates leases loaded by this process. * Answers with the refusal attach owes its caller, or null once settled. */ export function createRestartReconciler(deps: { store: AgentSessionRecordStore diff --git a/src/main/runtime/agent-session-backup-recovery-fence.ts b/src/main/runtime/agent-session-backup-recovery-fence.ts index dc0fd835fa89..adbdf162cccf 100644 --- a/src/main/runtime/agent-session-backup-recovery-fence.ts +++ b/src/main/runtime/agent-session-backup-recovery-fence.ts @@ -1,14 +1,14 @@ -// Recovering the agent-session store from its backup, without minting a second writer. +// Importing the records file's backup, without minting a second writer. Only the one-time import +// into the chat database (agent-session-legacy-record-import.ts) applies this now. // // The backup is the previous committed generation. The commit that never landed may have granted a // fence chosen by `nextAgentSessionFence` from the backup lease, and // `isAgentSessionFenceCurrent` compares with STRICT EQUALITY. That choice may already be above // `runtimeFence + 1` after an earlier recovery; the new floor must strictly dominate it. // -// The bound is one lost mint per backup generation: each mint site uses -// `nextAgentSessionFence` once per transaction, and the save path aborts rather than advancing the -// primary past a stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected -// mint is not caught. +// The bound is one lost mint per backup generation: each mint site used `nextAgentSessionFence` +// once per transaction, and the file's save path aborted rather than advancing the primary past a +// stale backup. A source-level ratchet rejects direct `+ 1` mints; an indirected mint is not caught. // // This records a FLOOR for the next grant and leaves the current fence alone. Rewriting the current // fence is what an earlier version did, and it corrupted exactly the records it meant to save: a @@ -19,8 +19,7 @@ // Ownership is deliberately untouched. `claimStatus` (a conflict must survive restart), // `ownerProcess` (the identity evidence the owner probe needs — the lease owner is a child process // that can outlive a main-process crash) and `handoffStage` all carry forward verbatim. Loading -// already marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe -// once transactions are admitted. Nulling that evidence is how you get two writers on one provider +// marks every lease unreconciled, and the restart reconciler re-adjudicates them by probe. Nulling that evidence is how you get two writers on one provider // session; the fence protects the store, not the provider session. import { nextAgentSessionFence } from '../../shared/agent-session-next-fence' diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index b593908944fd..745de2c7a0a8 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -67,15 +67,13 @@ const OPERATION: AgentSessionOperationRow = { } function legacyFile( - records: readonly unknown[], + records: readonly { sessionId: string }[], extra: Record = {} ): Record { return { schemaVersion: 2, hostId: 'local', - records: Object.fromEntries( - records.map((value) => [(value as { sessionId: string }).sessionId, value]) - ), + records: Object.fromEntries(records.map((value) => [value.sessionId, value])), operations: {}, retiredClaimKeys: [], unusableRecords: {}, diff --git a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts index e2fb7c767d11..24d0a80b3d1d 100644 --- a/src/main/runtime/rpc/methods/agent-launch-replay.test.ts +++ b/src/main/runtime/rpc/methods/agent-launch-replay.test.ts @@ -420,10 +420,8 @@ describe('the recorded row stays readable by a build that predates it', () => { const outcome = Object.values(file.operations)[0].outcome // The ratchet, and the reason this is not a new status arm or an optional `sessionId`: a build - // without `launch` validates a row by these two fields, one row it rejects returns null for the - // whole file, and the schema version cannot be bumped to excuse it — a store is unreadable to - // any build whose version is higher than the file's. A downgrade must skip what it cannot - // understand, not lose every lease. + // without `launch` validates a row by these two fields and drops a row it rejects, losing its + // replay. A downgrade must skip only what it cannot understand. expect(outcome.status).toBe('succeeded') expect(typeof outcome.sessionId).toBe('string') expect(outcome.launch).toMatchObject({ outcome: { kind: 'terminal' } }) @@ -439,10 +437,9 @@ describe('an unreadable launch payload costs one replay, never the store', () => }) } - it('still admits the row, because one rejected row makes the whole file unparseable', () => { + it('still admits the row, because a load drops a row it rejects', () => { // The ratchet. `isAgentLaunchResult` mirrors a result type by hand, so a field tightened there - // would reject rows this same build wrote — and a primary and backup that both fail to parse - // raise `agent_session_store_corrupt`, taking every lease in the profile with them. + // would reject rows this same build wrote and lose their replay. expect( isAgentSessionOperationRow({ callerKey: 'device-1', diff --git a/src/shared/agent-session-next-fence.ts b/src/shared/agent-session-next-fence.ts index d5b2f2a08b3b..62e6da1d396b 100644 --- a/src/shared/agent-session-next-fence.ts +++ b/src/shared/agent-session-next-fence.ts @@ -1,9 +1,10 @@ // The only place a new fence number is chosen. // -// Normally that is just "one past the current fence". After the record store falls back to its -// backup it is not: the commit that never landed may already have granted a fence the backup cannot -// show, and `isAgentSessionFenceCurrent` compares with STRICT EQUALITY, so minting that exact -// number would hand a second writer a lease the first one still believes it holds. +// Normally that is just "one past the current fence". For a record the one-time import took from +// the records file's backup, or found beside a set-aside copy, it is not: the commit that never +// landed may already have granted a fence the copy cannot show, and `isAgentSessionFenceCurrent` +// compares with STRICT EQUALITY, so minting that exact number would hand a second writer a lease +// the first one still believes it holds. // // Recovery records the floor instead of rewriting the current fence, because `live` means a handle // proven at exactly the current fence — moving it would invalidate the very records recovery exists diff --git a/src/shared/agent-session-operation-ledger.ts b/src/shared/agent-session-operation-ledger.ts index 90caec22bb79..136b86af2b77 100644 --- a/src/shared/agent-session-operation-ledger.ts +++ b/src/shared/agent-session-operation-ledger.ts @@ -37,9 +37,8 @@ export type AgentSessionOperationOutcome = /** * Empty exactly when `launch` recorded a terminal surface — a PTY has a handle, not a session * id. Kept a required string rather than made optional because a build that predates `launch` - * rejects a `succeeded` row without one, and a single rejected row invalidates the whole - * store on load (`agent-session-record-store-file.ts`). A downgrade must skip what it cannot - * read, not lose every lease in the file. + * rejects a `succeeded` row without one: the records file such a build keeps is unusable to it + * whole, and a row the database holds is dropped from the ledger, so its retry runs again. */ sessionId: string conversationCommand?: AgentSessionConversationCommandResult @@ -50,12 +49,10 @@ export type AgentSessionOperationOutcome = * settings move: a replay must return what ran, not what would run now. * * Typed `unknown`, and deliberately NOT checked by `isAgentSessionOperationRow`, for the same - * reason `sessionId` above stays required: a row this file rejects makes the whole store - * unparseable, and a primary and backup that both fail to parse raise - * `agent_session_store_corrupt` rather than degrading. `isAgentLaunchResult` is a - * hand-maintained mirror of a result type later work will edit, so a field tightened there - * would reject rows this same build wrote and take every lease in the file with them. It is - * narrowed where the value is read instead, where a payload we cannot read costs one replay. + * reason `sessionId` above stays required: a load drops a row it rejects. `isAgentLaunchResult` + * is a hand-maintained mirror of a result type later work will edit, so a field tightened + * there would reject rows this same build wrote and lose their replay. It is narrowed where + * the value is read instead, where a payload we cannot read costs one replay. */ launch?: unknown } diff --git a/src/shared/agent-session-record.ts b/src/shared/agent-session-record.ts index 12a26e64ac77..b8280cf332da 100644 --- a/src/shared/agent-session-record.ts +++ b/src/shared/agent-session-record.ts @@ -120,8 +120,8 @@ export type AgentSessionLease = { /** True from load until the host adjudicates it; no writer is granted while set. */ unreconciled: boolean /** - * Lowest fence a future grant may use. Set only after the store recovers from its backup, where - * the commit that never landed may already have granted a fence the backup cannot show. The + * Lowest fence a future grant may use. Set only when the records file's copy came from its backup, + * or sat beside a set-aside copy of the same chat: either may hide a fence already granted. The * CURRENT fence is deliberately left alone: `live` means a handle proven at exactly that number, * so rewriting it would invalidate the record it is trying to save. */ From d05672d68b338e5820820f7ebe9aa9d813856486 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:14:46 -0700 Subject: [PATCH 12/30] test(native-chat): drop the record-store harness's leftover file path and type the import fixture --- .../runtime/agent-session-legacy-record-import.test.ts | 2 +- .../runtime/agent-session-record-store-test-harness.ts | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index 745de2c7a0a8..9a0a9d12b57f 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -67,7 +67,7 @@ const OPERATION: AgentSessionOperationRow = { } function legacyFile( - records: readonly { sessionId: string }[], + records: readonly (Record & { sessionId: string })[], extra: Record = {} ): Record { return { diff --git a/src/main/runtime/agent-session-record-store-test-harness.ts b/src/main/runtime/agent-session-record-store-test-harness.ts index ec9a3909f291..eae3ae3baf4c 100644 --- a/src/main/runtime/agent-session-record-store-test-harness.ts +++ b/src/main/runtime/agent-session-record-store-test-harness.ts @@ -15,22 +15,12 @@ import { import type Database from '../sqlite/sync-database' import { AgentSessionRecordStore } from './agent-session-record-store' import { - AGENT_SESSION_STORE_DIR_NAME, AGENT_SESSION_STORE_SCHEMA_VERSION, type RetiredAgentSessionClaimKey } from './agent-session-record-store-file' const TEST_HOST_ID = 'local' -function storeDirectory(stateDirectory: string): string { - return join(stateDirectory, AGENT_SESSION_STORE_DIR_NAME) -} - -/** The store's file, for the tests that assert on or damage its bytes directly. */ -export function testAgentSessionStoreFilePath(stateDirectory: string): string { - return agentSessionStorePath(storeDirectory(stateDirectory)) -} - /** One committed state of the store, as tests seed it and read it back. */ export type PersistedTestAgentSessionStore = { schemaVersion: number From 345da946b9efd2fde12cb46587cd2bb551e6b167 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:44:48 -0700 Subject: [PATCH 13/30] test(native-chat): let the host harness cleanup wait out a recovery-offer read's lock --- .../structured-agent-session-host-test-harness.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 1a701f5b13cc..96d572021359 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -163,7 +163,8 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() await journals.closeAll() - await rm(root, { recursive: true, force: true }) + // A recovery-offer read a host still has in flight holds its lock directory for a moment. + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }) }) /** A restarted process swaps the store and the host under the same directories. From a9b11978199d110467830665a72c9d3b5bec9934 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:46:41 -0700 Subject: [PATCH 14/30] fix(native-chat): let Stop reach the agent when its ledger row cannot be written Stop's operation-ledger row now shares the database with the chat history, so damage, a full disk or a stranded transaction on that write refused the Stop before the interrupt. A cancel plan now takes its decision from the committed ledger in memory, runs without settling, and warns that the row was skipped. Other mutations answer proven damage with the typed "Unable to load this chat." refusal instead of the raw SQLite error. --- ...d-agent-session-journal-corruption.test.ts | 106 ++++++++++++------ ...ctured-agent-session-mutation-admission.ts | 88 +++++++++++---- ...structured-agent-session-mutation-plans.ts | 4 + 3 files changed, 142 insertions(+), 56 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index f3aa105573e4..8001739e4a1b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -3,8 +3,8 @@ import { readdir } from 'node:fs/promises' import { afterEach, beforeEach, expect, it, vi, type Mock } from 'vitest' -import type Database from '../../sqlite/sync-database' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -16,44 +16,43 @@ import { import { hostTestMessage } from './structured-agent-session-host-test-data' let root: string +let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let cancelTurn: Mock beforeEach(() => { - ;({ root, host, cancelTurn } = hostTestState()) + ;({ root, store, host, cancelTurn } = hostTestState()) }) afterEach(() => vi.restoreAllMocks()) +const sqliteError = (message: string, errcode: number): Error => + Object.assign(new Error(message), { code: 'ERR_SQLITE_ERROR', errcode }) + +/** Every statement on the chat records' tables fails; the history beside them still works. */ +function failRecordStatements(error: Error): void { + const connection = openTestJournalHostDatabase(root).db + const prepare = connection.prepare.bind(connection) + vi.spyOn(connection, 'prepare').mockImplementation((sql: string) => { + if (sql.includes('agent_session_')) { + throw error + } + return prepare(sql) + }) +} + +const stop = (turnEnvelope = envelope('agentSession.cancel', { turnId: 'turn-1' })) => + host.cancel(CALLER, { envelope: turnEnvelope, turnId: 'turn-1' }) + // T-corrupt-midsession. it('refuses a send as corrupt when SQLite reports damage, and still stops the agent', async () => { await attach() const files = await readdir(root, { recursive: true }) - const damaged = Object.assign(new Error('database disk image is malformed'), { - code: 'ERR_SQLITE_ERROR', - errcode: 11 + const damaged = sqliteError('database disk image is malformed', 11) + vi.spyOn(openTestJournalHostDatabase(root), 'transaction').mockImplementation(() => { + throw damaged }) - // The damage is in the history's pages; the ownership rows beside it still read and write. - const database = openTestJournalHostDatabase(root) - const transaction = database.transaction.bind(database) - vi.spyOn(database, 'transaction').mockImplementation((run: (db: Database.Database) => T) => - transaction((db) => - run( - new Proxy(db, { - get: (target, property) => - property === 'prepare' - ? (sql: string) => { - if (!sql.includes('agent_session_')) { - throw damaged - } - return target.prepare(sql) - } - : Reflect.get(target, property, target) - }) - ) - ) - ) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) const body = hostTestMessage('after the damage') const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) @@ -66,15 +65,54 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag details: { reason: 'journalCorrupt' } } }) - // Stop reaches the agent before it writes anything; the note it then cannot record is the - // error the caller sees, after the fact. - await expect( - host.cancel(CALLER, { - envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), - turnId: 'turn-1' - }) - ).rejects.toBe(damaged) + // Stop reaches the agent without its ledger row; the note it then cannot record is the error + // the caller sees, after the fact. + await expect(stop()).rejects.toBe(damaged) expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + sessionId: expect.any(String), + error: 'database disk image is malformed' + }) // A recovery-offer read still in flight holds its lock for a moment; nothing else may appear. await vi.waitFor(async () => expect(await readdir(root, { recursive: true })).toEqual(files)) }) + +it.each([ + ['damaged', sqliteError('database disk image is malformed', 11)], + ['full', sqliteError('database or disk is full', 13)] +])('stops the agent when the records are %s and the history is not', async (_, error) => { + await attach() + failRecordStatements(error) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(stop()).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { turnId: 'turn-1', cancelled: true } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(warn).toHaveBeenCalledWith("[agent-session] Stop's ledger row skipped:", { + sessionId: expect.any(String), + error: error.message + }) +}) + +it('replays a recorded Stop from memory when its ledger cannot be written', async () => { + await attach() + const stopEnvelope = envelope('agentSession.cancel', { turnId: 'turn-1' }) + await expect(stop(stopEnvelope)).resolves.toMatchObject({ ok: true, replayed: false }) + expect(cancelTurn).toHaveBeenCalledTimes(1) + // A replay writes nothing on a healthy store; one that refuses every transaction still throws. + vi.spyOn(store, 'admitMutationOperation').mockRejectedValue( + sqliteError('database disk image is malformed', 11) + ) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // Interrupting twice would stop a turn the client never asked to stop. + await expect(stop(stopEnvelope)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { turnId: 'turn-1', cancelled: false } + }) + expect(cancelTurn).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index fe0314a28a49..3e96a02ce26a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -23,7 +23,15 @@ import { } from '../../../shared/agent-session-wire' import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' +import type { + AgentSessionMutationOperationAdmission, + AgentSessionMutationOperationDecision +} from '../../runtime/agent-session-operation-admission' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + classifyJournalOpenFailure, + journalOpenRefusal +} from '../agent-session-journal/journal-open-failure' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { MutationPlan } from './structured-agent-session-mutation-plans' @@ -105,22 +113,31 @@ export async function admitAndRunAgentSessionMutation( if (!journal) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } - let admitted: Awaited> + const operation: AgentSessionMutationOperationAdmission = { + callerKey: request.callerKey, + envelope, + hostFingerprint, + now: request.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), + ...(plan.conversationWrite ? { conversationWrite: true } : {}) + } + let admitted: AgentSessionMutationOperationDecision + let ledgerRowWritten = true try { - admitted = await request.store.admitMutationOperation({ - callerKey: request.callerKey, - envelope, - hostFingerprint, - now: request.now(), - ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), - ...(plan.conversationWrite ? { conversationWrite: true } : {}) - }) + admitted = await request.store.admitMutationOperation(operation) } catch (error) { - // A store that refuses the ledger row (records a newer Orca wrote) answers as that refusal. - if (isAgentSessionRefusalError(error)) { - return refuseAgentSessionMutation(error.refusal) + if (plan.runsWithoutLedgerRow) { + admitted = admitWithoutLedgerRow(request.store, operation, error) + ledgerRowWritten = false + } else if ( + isAgentSessionRefusalError(error) || + classifyJournalOpenFailure(error) === 'journalCorrupt' + ) { + // A store refusing the row (a newer Orca's records) or damage SQLite proves: as an open says. + return refuseAgentSessionMutation(journalOpenRefusal(error)) + } else { + throw error } - throw error } if (!admitted) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) @@ -162,20 +179,47 @@ export async function admitAndRunAgentSessionMutation( } } - const outcome = await runSettledAgentSessionMutation({ - store: request.store, - // A global send replay can cross caller identities. Settlement still owns - // the durable row admitted by the original caller. - operationCallerKey: admission.row.callerKey, - envelope, - plan, - context - }) + // With no row, a settle fails on the same storage and turns a landed Stop into a throw. + const outcome = ledgerRowWritten + ? await runSettledAgentSessionMutation({ + store: request.store, + // A global send replay can cross caller identities. Settlement still owns + // the durable row admitted by the original caller. + operationCallerKey: admission.row.callerKey, + envelope, + plan, + context + }) + : await plan.run(context) return outcome.ok ? { ok: true, replayed: false, fence, cursor: journal.cursor(), value: outcome.value } : refuseAgentSessionMutation(outcome.refusal) } +/** The committed ledger's admission, placing nothing: a failed commit left memory as it was. */ +function admitWithoutLedgerRow( + store: AgentSessionRecordStore, + operation: AgentSessionMutationOperationAdmission, + error: unknown +): AgentSessionMutationOperationDecision { + console.warn("[agent-session] Stop's ledger row skipped:", { + sessionId: operation.envelope.sessionId, + error: error instanceof Error ? error.message : String(error) + }) + const evaluated = store.evaluateMutationOperation(operation) + if (!evaluated) { + return null + } + const admission = admitAgentSessionMutation({ + envelope: operation.envelope, + hostFingerprint: operation.hostFingerprint, + ledger: evaluated.decision, + lease: evaluated.record.lease, + ...(operation.conversationWrite ? { conversationWrite: true } : {}) + }) + return { admission, record: evaluated.record } +} + function turnContext( request: AgentSessionMutationRequest, journal: AgentSessionJournal, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 388fabd7bd45..7eecbd50db93 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -48,6 +48,8 @@ export type MutationPlan = { operationIdScope?: 'global' /** Admitted without the writer lease: see `admitAgentSessionMutation`. */ conversationWrite?: true + /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ + runsWithoutLedgerRow?: true markUnknownBeforeRun?: boolean run: (ctx: AgentSessionTurnContext) => Promise> replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null @@ -176,6 +178,8 @@ export function cancelPlan(params: { method: 'agentSession.cancel', // Stop is a conversation write; a prompt or background-task cancel needs the live child. ...(params.scope || params.prompt ? {} : { conversationWrite: true as const }), + // A Stop must reach the agent even when storage refuses the row recording it. + runsWithoutLedgerRow: true, fields: { ...(params.turnId !== undefined ? { turnId: params.turnId } : {}), ...(params.scope ? { scope: params.scope } : {}), From b2370d50767f01eb1dab41bd3a43547594a1281d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:49:23 -0700 Subject: [PATCH 15/30] fix(native-chat): answer whether a profile holds chats from the database's rows Every host install creates agent-session-journal.db, chats or not, and the version probe created it too, so its mere existence made every profile that ever installed the host wait on host install and reconcile at startup. The check now opens the database read-only and looks for a record or tab row, lets the records file answer while its import is still owed, and counts an unreadable database as present. The version probe no longer creates the file. --- ...journal-database-records-migration.test.ts | 8 +++ .../agent-session-journal/journal-database.ts | 31 +++++++++ .../structured-agent-session-runtime.test.ts | 68 +++++++++++++++++-- .../structured-agent-session-runtime.ts | 23 +++++-- 4 files changed, 116 insertions(+), 14 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts index 3286e5070b29..120588b306dd 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-records-migration.test.ts @@ -1,6 +1,7 @@ // 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' @@ -10,6 +11,7 @@ import { journalPragmaNumber, NO_LEGACY_JOURNAL_RECORDS, openJournalDatabase, + readJournalDatabaseVersion, type JournalLegacyRecordImport } from './journal-database' import { createJournalTablesSql } from './journal-database-schema' @@ -164,6 +166,12 @@ describe('the version-4 migration', () => { }) }) + // 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() diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index a63c59248d31..a306ad6e564e 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -4,6 +4,7 @@ // 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 { @@ -56,7 +57,11 @@ export function journalDatabaseMigratesRecords(stored: number): boolean { ) } +/** 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') @@ -65,6 +70,32 @@ export function readJournalDatabaseVersion(dbPath: string): number { } } +/** + * Whether the database holds any chat record or tab, read-only. `undefined` while version 4's copy + * of the records file is still owed, so the file answers for the chats it holds. + */ +export function journalDatabaseHoldsAgentSessions(dbPath: string): boolean | undefined { + const db = new Database(dbPath, { readonly: true, fileMustExist: true }) + try { + const tables = new Set( + db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() + .map(({ name }) => name) + ) + const holds = ['agent_session_records', 'agent_session_tabs'].some( + (table) => + tables.has(table) && db.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get() !== undefined + ) + if (holds || journalPragmaNumber(db, 'user_version') >= JOURNAL_DB_SCHEMA_VERSION) { + return holds + } + return undefined + } finally { + db.close() + } +} + export function openJournalDatabase( dbPath: string, legacyRecords: JournalLegacyRecordImport diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index a6a1ff9717af..e1d37c452a1e 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -1,6 +1,6 @@ -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionClaimStatus, @@ -10,6 +10,15 @@ import type { } from '../../shared/agent-session-record' import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' +import { + NO_LEGACY_JOURNAL_RECORDS, + type JournalLegacyRecordImport +} from '../native-chat/agent-session-journal/journal-database' +import { + JournalHostDatabase, + journalDatabasePath +} from '../native-chat/agent-session-journal/journal-host-database' +import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -54,12 +63,57 @@ const OWNER: AgentSessionProcessIdentity = { const deadProbe = () => vi.fn(async () => ({ outcome: 'pid-absent' }) as const) describe('structured agent-session store presence', () => { - it('stops after finding the chat journal database', () => { - const fileExists = vi.fn(() => true) + let profile: string + afterEach(async () => { + if (profile) { + await rm(profile, { recursive: true, force: true }) + profile = '' + } + }) - expect(hasPersistedStructuredAgentSessionStore('/profile', fileExists)).toBe(true) - expect(fileExists).toHaveBeenCalledOnce() - expect(fileExists).toHaveBeenCalledWith(join('/profile', 'agent-session-journal.db')) + async function openProfileDatabase( + legacyRecords: JournalLegacyRecordImport = NO_LEGACY_JOURNAL_RECORDS + ): Promise { + profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) + return JournalHostDatabase.openWith(profile, legacyRecords) + } + + async function writeRecordsFile(): Promise { + const filePath = legacyAgentSessionStorePath(profile) + await mkdir(dirname(filePath), { recursive: true }) + await writeFile(filePath, '{}') + } + + // Every host install creates the database, chats or not; startup restore must not wait on one. + it('reports a profile whose database holds no chat absent', async () => { + ;(await openProfileDatabase()).close() + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false) + }) + + it('reports a chat record in the database', async () => { + const database = await openProfileDatabase() + database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run('session-1', '{}') + database.close() + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) + }) + + it('lets the records file answer while the database still owes its copy', async () => { + ;(await openProfileDatabase({ owed: true })).close() + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(false) + + await writeRecordsFile() + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) + }) + + it('reports a database it cannot read present', async () => { + profile = await mkdtemp(join(tmpdir(), 'orca-session-presence-')) + await writeFile(journalDatabasePath(profile), 'not a database') + + expect(hasPersistedStructuredAgentSessionStore(profile)).toBe(true) }) // A profile from before the records moved into the database still holds a chat to import. diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 4e8157e5f14c..dd5e4b4302f3 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -43,6 +43,7 @@ import type { JournalHostDatabase } from '../native-chat/agent-session-journal/j import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { legacyAgentSessionStorePath } from './agent-session-record-store-file' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { journalDatabaseHoldsAgentSessions } from '../native-chat/agent-session-journal/journal-database' import { createStructuredAgentSessionOwnerProbe, createStructuredAgentSessionOwnerProbes @@ -58,18 +59,26 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -/** Whether this profile ever held a structured chat: the journal database, or the records file a - * profile from before it still carries. */ +/** Whether this profile holds a structured chat: a record or tab in the journal database, or the + * records file a profile from before it carries while the database still owes its copy. */ export function hasPersistedStructuredAgentSessionStore( stateDirectory: string, fileExists: (path: string) => boolean = existsSync ): boolean { + const databasePath = journalDatabasePath(stateDirectory) + if (fileExists(databasePath)) { + try { + const holds = journalDatabaseHoldsAgentSessions(databasePath) + if (holds !== undefined) { + return holds + } + } catch { + // A database that cannot be read cannot say it is empty. + return true + } + } const filePath = legacyAgentSessionStorePath(stateDirectory) - return ( - fileExists(journalDatabasePath(stateDirectory)) || - fileExists(filePath) || - fileExists(`${filePath}.bak`) - ) + return fileExists(filePath) || fileExists(`${filePath}.bak`) } export type StructuredAgentSessionRuntimeDeps = { From e14332d50c23df72699e20bf15b64fa17335b0ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:53:00 -0700 Subject: [PATCH 16/30] fix(native-chat): open a chat from history when its tab index cannot be written Over records a newer Orca wrote, every write is refused, so opening a closed chat from Agent Session History failed on the tab-visibility write and the chat read as unreachable. Like closing a tab, opening one now reports a failed restore-index write and still publishes the tab. --- ...tore-structured-agent-session-tabs-once.ts | 12 ++++---- .../methods/structured-chat-tab-table.test.ts | 30 ++++++++++++++++++- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 1cae0faef124..60b65bcf052d 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -116,11 +116,13 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu }): Promise { const host = getStructuredAgentSessionHost() if (typeof host?.setSessionTabVisibility === 'function') { - await host.setSessionTabVisibility( - input.sessionId, - true, - ...(input.tabId ? [input.tabId] : []) - ) + // The restore index is bookkeeping: one that cannot be written (a newer Orca's records, a + // failing disk) is reported, and the tab still opens. + await host + .setSessionTabVisibility(input.sessionId, true, ...(input.tabId ? [input.tabId] : [])) + .catch((error: unknown) => { + console.warn('[structured-agent-session] recording an opened chat tab failed', error) + }) } this.projectStructuredAgentSessionTab(input) } diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 69109395eaf5..4907d5cb3c0f 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -25,7 +25,10 @@ import { } from '../../../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' import type { AgentSessionRecordStore } from '../../agent-session-record-store' -import { openTestAgentSessionRecordStore } from '../../agent-session-record-store-test-harness' +import { + openTestAgentSessionRecordStore, + seedTestAgentSessionStoreFromNewerBuild +} from '../../agent-session-record-store-test-harness' import { OrcaRuntimeService } from '../../orca-runtime' import { RpcDispatcher } from '../dispatcher' import type { RpcDispatchStreamingOptions } from '../dispatcher-stream-options' @@ -190,6 +193,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() await host?.flushAllStreamedEvents() setStructuredAgentSessionHost(null) await rm(directory, { recursive: true, force: true }) @@ -384,3 +388,27 @@ describe('a create that reserves its tab', () => { }) }) }) + +describe('a chat tab over records a newer Orca wrote', () => { + it('opens a closed chat from history for reading', async () => { + await createChat(HOST_TEST_SESSION) + await host.close(HOST_TEST_SESSION) + await host.setSessionTabVisibility(HOST_TEST_SESSION, false) + await host.flushAllStreamedEvents() + await seedTestAgentSessionStoreFromNewerBuild(directory) + await openHost() + expect(store.readOnly).toBe(true) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + expect(await call('agentSession.reveal', { sessionId: HOST_TEST_SESSION })).toMatchObject({ + ok: true, + result: { ok: true, readable: true } + }) + + expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording an opened chat tab failed', + expect.anything() + ) + }) +}) From b0d693dc043b1eff52c619a536d6efff925412fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:54:06 -0700 Subject: [PATCH 17/30] fix(native-chat): keep the records import owed when the backup read fails transiently A torn records file whose .bak could not be read (EACCES, EIO) was reported as unusable, so the migration completed with nothing copied and never retried. A non-ENOENT read failure of either copy now carries its cause, which the importer classifies as a read that can clear. --- .../agent-session-legacy-record-import.test.ts | 12 ++++++++++++ .../runtime/agent-session-legacy-record-import.ts | 2 +- src/main/runtime/agent-session-record-store-file.ts | 9 +++------ 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index 9a0a9d12b57f..e658615276d6 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -299,6 +299,18 @@ describe('a primary the backup stands in for', () => { 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([]) + }) }) describe('the tab index from before the table', () => { diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts index 4cb2140fb505..72f6bb688326 100644 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -32,7 +32,7 @@ export type LegacyAgentSessionRecords = | { 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 failed in a way that can clear (EACCES, EIO, EMFILE). */ + /** 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. */ diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index 70dbbf65580e..e25598724ddd 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -259,12 +259,9 @@ export async function loadAgentSessionStore( } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { // Only a missing or unparseable primary means "fall back". A transient read failure - // (EACCES, EIO, EMFILE) says nothing about the primary's contents, and treating it as - // recovery would replace newer state with a stale backup and latch the recovery path. - if (!recoveredFromBackup) { - throw new Error('agent_session_store_corrupt', { cause: error }) - } - unusableStoreFound = true + // (EACCES, EIO, EMFILE) says nothing about either copy's contents: the primary is not + // replaced by a stale backup, and a backup that could not be read is not unusable. + throw new Error('agent_session_store_corrupt', { cause: error }) } continue } From 56e846013791c354dc2390255e26a862b84f81d9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:54:10 -0700 Subject: [PATCH 18/30] test(native-chat): pin that an unreadable records file never falls back to its backup --- .../agent-session-legacy-record-import.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index e658615276d6..883502e410e9 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -311,6 +311,18 @@ describe('a primary the backup stands in for', () => { 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', () => { From e81973067db9a578a20130ad4211a92dff08da99 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:55:01 -0700 Subject: [PATCH 19/30] fix(native-chat): restore imported chats' tabs when the records file had no tab index A chat created while the import was owed recorded a tab index holding only itself. When the file it later imported had no index, that index still read as recorded, so the imported chats' tabs never came back. The import now clears the recorded marker in that case, and restore falls back to the profile's tabs. --- ...agent-session-legacy-record-import.test.ts | 21 +++++++++++++++++++ src/main/runtime/agent-session-record-rows.ts | 4 ++++ 2 files changed, 25 insertions(+) diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index 883502e410e9..e078f6b19cab 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -376,6 +376,27 @@ describe('the tab index from before the table', () => { expect(store.listVisibleSessionIds()).toEqual([ALPHA, 'clear-two']) }) + it('leaves the index unrecorded when only a chat created while the copy was owed recorded one', async () => { + await mkdir(legacyPath(), { recursive: true }) + const owed = await install() + owed.database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run(BETA, JSON.stringify(record(BETA))) + await AgentSessionRecordStore.open({ + journalDatabase: owed.database, + hostId: 'local' + }).setSessionTabVisibility(BETA, true) + owed.database.close() + await rm(legacyPath(), { recursive: true }) + await writeLegacy(legacyFile([record(ALPHA)])) + + const { database, store } = await install() + + expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) + expect(store.listRecords().map(({ sessionId }) => sessionId)).toEqual([BETA, ALPHA]) + expect(store.getVisibleSessionTabIndex().present).toBe(false) + }) + it('reads a recorded table, never the record field', async () => { await writeLegacy( legacyFile([{ ...record(ALPHA), surfaceTabId: 'tab-stale' }], { diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index d298f93d1d53..75ee8a084e87 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -266,5 +266,9 @@ export function insertAgentSessionStoreRowsIfAbsent( 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, which hold them all. + db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) } } From 425d5354f4e151376b6be47114d96ce732bddcab Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:55:32 -0700 Subject: [PATCH 20/30] refactor(native-chat): drop the unused in-transaction store write Nothing called it, and it bypassed the write queue and the read-only refusal. --- .../runtime/agent-session-store-transactions.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 3ec3e7ca4822..879ff4a160e8 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -7,7 +7,6 @@ // a BEGIN, which `runJournalTransaction` does not support. The queue also keeps the FIFO order and // the async boundary every awaiting caller was written against. -import type Database from '../sqlite/sync-database' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' @@ -105,22 +104,6 @@ export class AgentSessionStoreTransactions { return run } - /** - * The same synchronous body for a caller already inside a journal transaction on `db`, so a - * record change can commit with the journal rows it belongs with. Call `adopt` only after that - * transaction commits. - */ - applyInTransaction( - db: Database.Database, - apply: (draft: AgentSessionStoreState) => T - ): { result: T; adopt: () => void } { - const staged = this.stage(apply) - if (staged.writes) { - writeAgentSessionStoreRows(db, staged.writes) - } - return { result: staged.result, adopt: staged.adopt } - } - private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { const readOnly = this.journalDatabase.readOnly if (readOnly && !inMemoryWhenReadOnly) { From c2afb8c0153db7b573c4abe2439c58e8c8f9cf73 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:57:07 -0700 Subject: [PATCH 21/30] docs(native-chat): say that an unusable records file is left untouched but never re-imported --- src/main/runtime/agent-session-legacy-record-import.test.ts | 2 +- src/main/runtime/agent-session-legacy-record-import.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index e078f6b19cab..3e5bd97f6468 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -159,7 +159,7 @@ describe('copying the records file into the chat database', () => { expect(store.getRecord(BETA)).toBeNull() }) - it('reports a file no read will make usable, copies nothing, and leaves it for recovery', async () => { + 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) diff --git a/src/main/runtime/agent-session-legacy-record-import.ts b/src/main/runtime/agent-session-legacy-record-import.ts index 72f6bb688326..0a0b4d49d5bb 100644 --- a/src/main/runtime/agent-session-legacy-record-import.ts +++ b/src/main/runtime/agent-session-legacy-record-import.ts @@ -3,9 +3,9 @@ // 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 stays for manual recovery. 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. +// 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' From 2984a311351a53f0b9f840041d71885b50ba8a27 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:57:49 -0700 Subject: [PATCH 22/30] refactor(native-chat): keep the provider handle chain check as main has it The chain-validation refactor has no measured need in this change. --- .../agent-session-provider-handle.test.ts | 38 ---------- src/shared/agent-session-provider-handle.ts | 70 ++++++------------- 2 files changed, 20 insertions(+), 88 deletions(-) diff --git a/src/shared/agent-session-provider-handle.test.ts b/src/shared/agent-session-provider-handle.test.ts index 5ca622eeac6d..7474c751cbe0 100644 --- a/src/shared/agent-session-provider-handle.test.ts +++ b/src/shared/agent-session-provider-handle.test.ts @@ -299,44 +299,6 @@ describe('chain lookup and validation', () => { ]) ).toBe(false) }) - - it('validates a chain in work linear in its length', () => { - /** Property reads on every link of a valid chain of `length` resumes, counted per link. */ - const readsToValidate = (length: number): number[] => { - const reads = Array.from({ length }, () => 0) - const chain = Array.from( - { length }, - (_value, index) => - new Proxy( - link( - index === 0 - ? {} - : { - linkId: `link-${index + 1}`, - origin: 'resumed', - handle: { ...CLAUDE, leafUuid: `leaf-${index + 1}` } - } - ), - { - get: (target, property, receiver) => { - reads[index] += 1 - // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy get trap default forward. - return Reflect.get(target, property, receiver) - } - } - ) - ) - expect(isAgentSessionProviderHandleChain(chain)).toBe(true) - return reads - } - const half = readsToValidate(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS / 2) - const full = readsToValidate(MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) - const total = (reads: number[]) => reads.reduce((sum, count) => sum + count, 0) - - // Each link is read a fixed number of times however long the chain is. - expect(Math.max(...full)).toBe(Math.max(...half)) - expect(total(full)).toBeLessThanOrEqual(2 * total(half) + Math.max(...full)) - }) }) describe('adopted chain heads', () => { diff --git a/src/shared/agent-session-provider-handle.ts b/src/shared/agent-session-provider-handle.ts index c3f339a7ce44..ea789dcdfe40 100644 --- a/src/shared/agent-session-provider-handle.ts +++ b/src/shared/agent-session-provider-handle.ts @@ -140,20 +140,18 @@ export function isAgentSessionProviderHandleChain( if (!Array.isArray(value) || value.length > MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { return false } - const seenLinkIds = new Set() - let head: AgentSessionProviderHandleLink | null = null + let validated: AgentSessionProviderHandleLink[] = [] try { for (const link of value) { if (!isAgentSessionProviderHandleLink(link)) { return false } - // A persisted chain must name every link exactly once; retry elision and supersession belong - // at append time. - if (agentSessionProviderHandleTransition(head, link, seenLinkIds) !== 'append') { + const next = appendAgentSessionProviderHandleLink(validated, link) + // A persisted chain must name every link exactly once; retry elision belongs at append time. + if (next.length !== validated.length + 1) { return false } - seenLinkIds.add(link.linkId) - head = link + validated = next } return true } catch { @@ -172,41 +170,12 @@ export function appendAgentSessionProviderHandleLink( if (!isAgentSessionProviderHandleLink(link)) { throw new Error('agent_session_provider_handle_invalid') } - const seenLinkIds = new Set(chain.map((existing) => existing.linkId)) - switch ( - agentSessionProviderHandleTransition( - agentSessionProviderHandleChainHead(chain), - link, - seenLinkIds - ) - ) { - case 'retry': - return [...chain] - case 'supersede': - // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. - return [link] - case 'append': - return [...chain, link] - } -} - -export type AgentSessionProviderHandleTransition = 'append' | 'retry' | 'supersede' - -/** - * What `link` does to a chain whose head is `head` and which already names `seenLinkIds`; throws - * when it is no legal successor. The one chain rule: appending and validating a persisted chain - * both go through it. `link` must already satisfy `isAgentSessionProviderHandleLink`. - */ -export function agentSessionProviderHandleTransition( - head: AgentSessionProviderHandleLink | null, - link: AgentSessionProviderHandleLink, - seenLinkIds: ReadonlySet -): AgentSessionProviderHandleTransition { + const head = agentSessionProviderHandleChainHead(chain) if (!head) { if (link.origin !== 'created' && link.origin !== 'adopted') { throw new Error('agent_session_provider_handle_invalid') } - return 'append' + return [link] } if (link.handle.provider !== head.handle.provider) { throw new Error('agent_session_provider_handle_provider_mismatch') @@ -215,8 +184,7 @@ export function agentSessionProviderHandleTransition( throw new Error('agent_session_provider_handle_stale_fence') } if (link.origin === 'created' && link.supersedesKey !== undefined) { - assertSupersedesUnsavedCreation(head, link) - return 'supersede' + return supersedeUnsavedCreation(head, link) } if (link.origin === 'created' || link.origin === 'adopted') { throw new Error('agent_session_provider_handle_invalid') @@ -242,30 +210,30 @@ export function agentSessionProviderHandleTransition( link.mintedAtFence === head.mintedAtFence ) { // Why: re-proving the same handle at the same fence is a retry, not a new identity. - return 'retry' + return [...chain] } - if (seenLinkIds.has(link.linkId)) { + if (findAgentSessionProviderHandleLink(chain, link.linkId)) { // Why: the lease names its exact proof by link id; reuse would make that reference ambiguous. throw new Error('agent_session_provider_handle_invalid') } - if (seenLinkIds.size >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { + if (chain.length >= MAX_AGENT_SESSION_PROVIDER_HANDLE_LINKS) { // Why: dropping older links would erase fork provenance, so refuse and let the caller roll // the journal epoch instead of silently losing where this conversation came from. throw new Error('agent_session_provider_handle_chain_overflow') } - return 'append' + return [...chain, link] } /** - * Only the chain's only link, a creation the provider proved it never saved, can be replaced by - * the creation that took its place. Every other head names a conversation the provider held (a - * resume or fork proved it, an adoption imported it), so only a `created` head can be superseded, - * and only by a new identity root that names it. + * Replace the chain's only link, a creation the provider proved it never saved, with the creation + * that took its place. Every other head names a conversation the provider held (a resume or fork + * proved it, an adoption imported it), so only a `created` head can be superseded, and only by a + * new identity root that names it. */ -function assertSupersedesUnsavedCreation( +function supersedeUnsavedCreation( head: AgentSessionProviderHandleLink, link: AgentSessionProviderHandleLink -): void { +): AgentSessionProviderHandleLink[] { if ( head.origin !== 'created' || link.supersedesKey !== agentSessionProviderHandleKey(head.handle) || @@ -274,4 +242,6 @@ function assertSupersedesUnsavedCreation( ) { throw new Error('agent_session_provider_handle_invalid') } + // Why: in place, so a chat reopened unused across many restarts never grows toward the cap. + return [link] } From 496a05ce7e20a5c7db868f96322c03ed722d030b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:58:22 -0700 Subject: [PATCH 23/30] docs(native-chat): retire lease-renewer comments that describe the records file as the live store --- .../structured-agent-session-lease-renewer.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts index a61e11b6f4f1..21d790202709 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-lease-renewer.ts @@ -36,8 +36,8 @@ export class StructuredAgentSessionLeaseRenewer { } /** Clearing the interval only stops the NEXT tick. A tick already past its guard still has a - * store transaction to commit, and that transaction re-creates the store directory, so a stop - * that returned before it landed would let the write outlive whatever tore the host down. */ + * store transaction to commit, so a stop that returned before it landed would let the write + * outlive whatever tore the host down. */ stop(): Promise { if (this.timer) { clearInterval(this.timer) @@ -91,8 +91,8 @@ export class StructuredAgentSessionLeaseRenewer { now }) } - // The store persists the whole record file per transaction, so keep the healthy path to - // one commit. If one renewal is superseded, retrying individually preserves isolation. + // One transaction for every renewal on the healthy path. If one renewal is superseded, + // retrying individually preserves isolation. let results: PromiseSettledResult[] try { const renewed = await this.input.store.renewLeases(renewals) From 1df09d5e6bfeb447788932d93c3000bce6be2392 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:15:13 -0700 Subject: [PATCH 24/30] fix(native-chat): keep a throwing failure sink from failing the startup chat read The lease bookkeeping failure reporter called the host's failure sink directly, so a sink that threw turned a reported, recoverable store failure back into a rejected startup reconcile or read restore. The reporter now catches a sink throw and logs both the original failure and the sink error with console.warn. --- ...uctured-agent-session-restart-reconcile.ts | 10 +++++++- ...-session-startup-reconcile-failure.test.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts index 23f0e0c6bfae..f349f252ebed 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-reconcile.ts @@ -62,7 +62,15 @@ export function reportEachFailureOnce( const key = failureKey(failure) if (key !== reported) { reported = key - onFailure?.(failure) + try { + onFailure?.(failure) + } catch (sinkError) { + // A throwing sink must not turn the reported failure back into a failed read. + console.warn('[structured-agent-session] reporting a lease bookkeeping failure failed', { + failure, + sinkError + }) + } } }, clear: () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts index 984475c4dd71..1d7b6621b3ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-startup-reconcile-failure.test.ts @@ -194,3 +194,27 @@ it('restores a chat for reading when resolving its recovery cannot write the sto expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() expect(onLeaseReconcileFailure).toHaveBeenCalledWith(expect.objectContaining({ code: 'ELOCKED' })) }) + +it.each([ + ['startup reconcile', (host: StructuredAgentSessionHost) => host.reconcileRestartLeases()], + ['read restore', (host: StructuredAgentSessionHost) => host.restoreReadableSessions([SESSION])] +])('keeps the %s resolving when the failure sink throws', async (_step, read) => { + const { host, onLeaseReconcileFailure } = await relaunch() + const sinkError = new Error('error sink failed') + onLeaseReconcileFailure.mockImplementation(() => { + throw sinkError + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + lock.failing = true + try { + await expect(read(host)).resolves.toBeUndefined() + + expect(onLeaseReconcileFailure).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('reporting a lease bookkeeping failure failed'), + expect.objectContaining({ failure: expect.objectContaining({ code: 'ELOCKED' }), sinkError }) + ) + } finally { + warn.mockRestore() + } +}) From 189baecaab7fd98fe1db7bfbc33a8dc10d39ca50 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:25:15 -0700 Subject: [PATCH 25/30] test(native-chat): wait for a replaced host's restart-offer writes before cleanup A restart test replaces the host without tearing the old one down, so the old host's fire-and-forget restart-offer withdrawal could still hold the recovery capsule's lock directory when cleanup removed the test directory (ENOTEMPTY). The harness now hands hosts a capsule that tracks running operations and waits for them before removing the directory, replacing the rm retries. --- ...uctured-agent-session-host-test-harness.ts | 17 +++-- ...gent-session-host-test-recovery-capsule.ts | 66 +++++++++++++++++++ ...d-agent-session-journal-corruption.test.ts | 6 +- 3 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts index 96d572021359..20a76aabfd3b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -1,5 +1,4 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -20,6 +19,7 @@ import type { } from './structured-agent-session-adapter' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { TrackedTestRecoveryCapsule } from './structured-agent-session-host-test-recovery-capsule' import { HOST_TEST_NOW as NOW, HOST_TEST_SESSION as SESSION, @@ -58,6 +58,7 @@ const attachParams = ( const ensureParams = (fence: number): AgentSessionAttachParams => hostTestAttachParams(fence) let root: string +let recoveryCapsule: TrackedTestRecoveryCapsule let store: AgentSessionRecordStore let host: StructuredAgentSessionHost let acquire: Mock @@ -149,11 +150,12 @@ beforeEach(async () => { answerPrompt = vi.fn(async ({ commit }) => commit()) setOption = vi.fn(async () => undefined) store = await openTestAgentSessionRecordStore(root) + recoveryCapsule = new TrackedTestRecoveryCapsule(root) host = new StructuredAgentSessionHost({ store, adapter: adapter(), journalDatabase: openTestJournalHostDatabase(root), - recoveryCapsule: new AgentSessionRecoveryCapsule(root), + recoveryCapsule, claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -163,10 +165,16 @@ beforeEach(async () => { afterEach(async () => { await host.flushAllStreamedEvents() await journals.closeAll() - // A recovery-offer read a host still has in flight holds its lock directory for a moment. - await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 }) + // A host a test replaced can still hold the capsule's lock directory under `root`. + await recoveryCapsule.settled() + await rm(root, { recursive: true, force: true }) }) +/** Waits out every recovery-capsule operation a host of this test started, replaced ones too. */ +function hostTestRecoveryCapsuleSettled(): Promise { + return recoveryCapsule.settled() +} + /** A restarted process swaps the store and the host under the same directories. * The helpers here close over both, so they have to be told. */ export function replaceHostTestState(next: { @@ -202,6 +210,7 @@ export { attachParams, ensureParams, envelope, + hostTestRecoveryCapsuleSettled, journals, seedApproval } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts new file mode 100644 index 000000000000..0319346a9ab1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-recovery-capsule.ts @@ -0,0 +1,66 @@ +// The host harness's recovery capsule. A host a test replaced keeps its restart-offer lane running +// (a withdrawal is fire-and-forget), and nothing else waits for it, so cleanup waits here before it +// removes the directory whose lock that operation holds. + +import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' + +type Capsule = AgentSessionRecoveryCapsule + +export class TrackedTestRecoveryCapsule extends AgentSessionRecoveryCapsule { + private readonly running = new Set>() + + /** Until no operation runs, including one a finished operation's lane starts next. */ + async settled(): Promise { + do { + await Promise.allSettled(this.running) + await new Promise((resolve) => setImmediate(resolve)) + } while (this.running.size > 0) + } + + override list(...args: Parameters) { + return this.track(super.list(...args)) + } + + override listFailed(...args: Parameters) { + return this.track(super.listFailed(...args)) + } + + override record(...args: Parameters) { + return this.track(super.record(...args)) + } + + override beginResume(...args: Parameters) { + return this.track(super.beginResume(...args)) + } + + override completeResume(...args: Parameters) { + return this.track(super.completeResume(...args)) + } + + override failResume(...args: Parameters) { + return this.track(super.failResume(...args)) + } + + override dismiss(...args: Parameters) { + return this.track(super.dismiss(...args)) + } + + override forgetSuperseded(...args: Parameters) { + return this.track(super.forgetSuperseded(...args)) + } + + override rollbackResume(...args: Parameters) { + return this.track(super.rollbackResume(...args)) + } + + override clearAll(...args: Parameters) { + return this.track(super.clearAll(...args)) + } + + private track(operation: Promise): Promise { + this.running.add(operation) + const forget = () => this.running.delete(operation) + operation.then(forget, forget) + return operation + } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts index 8001739e4a1b..ac4bb5e2f4d4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -11,6 +11,7 @@ import { attach, CALLER, envelope, + hostTestRecoveryCapsuleSettled, hostTestState } from './structured-agent-session-host-test-harness' import { hostTestMessage } from './structured-agent-session-host-test-data' @@ -73,8 +74,9 @@ it('refuses a send as corrupt when SQLite reports damage, and still stops the ag sessionId: expect.any(String), error: 'database disk image is malformed' }) - // A recovery-offer read still in flight holds its lock for a moment; nothing else may appear. - await vi.waitFor(async () => expect(await readdir(root, { recursive: true })).toEqual(files)) + // The restart-offer withdrawal the attach started holds its lock until it ends. + await hostTestRecoveryCapsuleSettled() + expect(await readdir(root, { recursive: true })).toEqual(files) }) it.each([ From 601ea4ce098bf6cc9346dddb46ba32627dbde50c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:25:18 -0700 Subject: [PATCH 26/30] docs(native-chat): retire the abandon helper's note that the store re-creates its directory --- .../structured-agent-session-host-test-abandon.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts index a4069875bade..78f4ab1beb5c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-abandon.ts @@ -5,10 +5,9 @@ // // What it cannot skip is work already in flight, which is what every copy of this helper used to // get wrong. Two producers reach the session store after the last awaited call has returned — a -// lease-renewal tick, and the restart the delivery loop wakes for an accepted send — and the store -// re-creates its own directory before every commit. A commit landing after the test removed its -// temp directory therefore puts that directory back, and the removal fails with ENOTEMPTY. Quit -// waits for both, in its `stop-lease-renewal` and `drain-attaches` phases; so does this. +// lease-renewal tick, and the restart the delivery loop wakes for an accepted send — and a commit +// landing after the test's cleanup meets a journal database already closed. Quit waits for both, +// in its `stop-lease-renewal` and `drain-attaches` phases; so does this. import type { StructuredAgentSessionHost } from './structured-agent-session-host' From 771bcb5d6e3002b0b2bbd3ce9f49fd5e1178ac69 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:57:43 -0700 Subject: [PATCH 27/30] fix(native-chat): restore a chat opened while the import was owed beside the profile's chats When the imported records file had no tab index, restore fell back to the profile's saved tabs, which never list a Claude chat, and the seed then rewrote the tab table without the chat opened while the import was owed. The tab rows that chat left are now loaded as unrecorded, restore takes them together with the profile's chats, and the seed keeps their tab ids. --- ...agent-session-legacy-record-import.test.ts | 3 +- src/main/runtime/agent-session-record-rows.ts | 34 +++++++-------- .../agent-session-record-store-file.ts | 2 + .../runtime/agent-session-record-store.ts | 7 +++- src/main/runtime/agent-session-tab-table.ts | 7 +++- ...tore-structured-agent-session-tabs-once.ts | 5 ++- ...-agent-session-startup-tab-restore.test.ts | 41 +++++++++++++++++++ ...structured-agent-session-tab-index-seed.ts | 3 +- 8 files changed, 81 insertions(+), 21 deletions(-) diff --git a/src/main/runtime/agent-session-legacy-record-import.test.ts b/src/main/runtime/agent-session-legacy-record-import.test.ts index 3e5bd97f6468..da09cb2257d8 100644 --- a/src/main/runtime/agent-session-legacy-record-import.test.ts +++ b/src/main/runtime/agent-session-legacy-record-import.test.ts @@ -394,7 +394,8 @@ describe('the tab index from before the table', () => { expect(journalPragmaNumber(database.db, 'user_version')).toBe(4) expect(store.listRecords().map(({ sessionId }) => sessionId)).toEqual([BETA, ALPHA]) - expect(store.getVisibleSessionTabIndex().present).toBe(false) + // 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 () => { diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index 75ee8a084e87..be6f70f06e7d 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -143,23 +143,25 @@ export function loadAgentSessionStoreRows( const recorded = db .prepare('SELECT 1 AS present FROM agent_session_store_meta WHERE key = ?') .get(SESSION_TABS_RECORDED) - if (recorded) { - const table = new AgentSessionTabTable() - for (const row of db - .prepare( - 'SELECT tab_id AS tabId, session_id AS sessionId FROM agent_session_tabs ORDER BY position' - ) - .all()) { - const entry = { tabId: row.tabId, sessionId: row.sessionId } - if ( - isReadableAgentSessionStoreTab(entry) && - table.sessionIdFor(entry.tabId) === undefined && - table.tabIdFor(entry.sessionId) === undefined - ) { - table.show(entry.sessionId, entry.tabId) - } + const table = new AgentSessionTabTable() + for (const row of db + .prepare( + 'SELECT tab_id AS tabId, session_id AS sessionId FROM agent_session_tabs ORDER BY position' + ) + .all()) { + const entry = { tabId: row.tabId, sessionId: row.sessionId } + if ( + isReadableAgentSessionStoreTab(entry) && + table.sessionIdFor(entry.tabId) === undefined && + table.tabIdFor(entry.sessionId) === undefined + ) { + table.show(entry.sessionId, entry.tabId) } + } + if (recorded) { state.sessionTabs = table + } else if (table.sessionIds().length > 0) { + state.unrecordedSessionTabs = table } return state } @@ -268,7 +270,7 @@ export function insertAgentSessionStoreRowsIfAbsent( ) } 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, which hold them all. + // "never recorded" sends restore to the profile's tabs, with that chat's tab row beside them. db.prepare('DELETE FROM agent_session_store_meta WHERE key = ?').run(SESSION_TABS_RECORDED) } } diff --git a/src/main/runtime/agent-session-record-store-file.ts b/src/main/runtime/agent-session-record-store-file.ts index e25598724ddd..79e65388fbd0 100644 --- a/src/main/runtime/agent-session-record-store-file.ts +++ b/src/main/runtime/agent-session-record-store-file.ts @@ -38,6 +38,8 @@ export type AgentSessionStoreState = { unreadableRecords: Map /** Chat tab id → the conversation it shows; null until this store first records a tab. */ sessionTabs: AgentSessionTabTable | null + /** Tab rows an index never recorded holds (chats opened while the import was owed). */ + unrecordedSessionTabs?: AgentSessionTabTable } export type LoadedAgentSessionStore = { diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 3992bb3503a3..300e48d370d9 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -109,9 +109,14 @@ export class AgentSessionRecordStore { this.state.records.has(sessionId) ) + /** Unrecorded, `sessionIds` are the tab rows a chat opened while the import was owed left. */ getVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } => ({ present: this.state.sessionTabs !== null, - sessionIds: this.listVisibleSessionIds() + sessionIds: this.state.sessionTabs + ? this.listVisibleSessionIds() + : (this.state.unrecordedSessionTabs?.sessionIds() ?? []).filter((sessionId) => + this.state.records.has(sessionId) + ) }) /** The id of the chat tab showing this conversation, if one does. */ diff --git a/src/main/runtime/agent-session-tab-table.ts b/src/main/runtime/agent-session-tab-table.ts index e48118830cd1..15ace0af7618 100644 --- a/src/main/runtime/agent-session-tab-table.ts +++ b/src/main/runtime/agent-session-tab-table.ts @@ -137,7 +137,12 @@ export function showAgentSessionTabs( ): void { for (const sessionId of sessionIds) { if (state.records.has(sessionId)) { - setAgentSessionTabVisibility(state, sessionId, true) + setAgentSessionTabVisibility( + state, + sessionId, + true, + state.unrecordedSessionTabs?.tabIdFor(sessionId) + ) } } } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 60b65bcf052d..f7866529e540 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -60,7 +60,10 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu const profileIds = collectSavedStructuredAgentSessionIds( this.store?.getWorkspaceSession?.(LOCAL_EXECUTION_HOST_ID) ?? null ) - const targets = persistedVisibleIndex.present ? persistedVisibleIndex.sessionIds : profileIds + // Unrecorded, the profile's chats join the tabs chats opened while the import was owed left. + const targets = persistedVisibleIndex.present + ? persistedVisibleIndex.sessionIds + : [...new Set([...persistedVisibleIndex.sessionIds, ...profileIds])] await host?.restoreReadableSessions(targets) for (const worktreeId of this.getKnownWorkspaceSessionWorktreeIds()) { this.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(worktreeId, { diff --git a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts index 1e843c5f549d..84c14b2e23cd 100644 --- a/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts +++ b/src/main/runtime/structured-agent-session-startup-tab-restore.test.ts @@ -58,6 +58,7 @@ const PROMPT = 'add a retry' const CHAT_A = 'chat-a-0001' const CHAT_B = 'chat-b-0002' const CLEARED = 'chat-s-0003' +const OWED = 'chat-o-0004' let root: string @@ -168,6 +169,26 @@ async function seedProfile( return { path: journalDatabasePath(root) } } +/** A chat opened, with its tab, while the records file could not be read and the copy was owed. */ +async function seedChatOpenedWhileOwed(record: AgentSessionRecord, tabId: string): Promise { + // A directory where the file belongs: the read fails in a way that can clear. + await mkdir(legacyAgentSessionStorePath(root), { recursive: true }) + const database = await openStructuredAgentSessionJournalDatabase({ + stateDirectory: root, + hostId: 'local', + onLegacyRecordImportReport: () => undefined + }) + database.db + .prepare('INSERT INTO agent_session_records (session_id, record_json) VALUES (?, ?)') + .run(record.sessionId, JSON.stringify(record)) + await AgentSessionRecordStore.open({ + journalDatabase: database, + hostId: 'local' + }).setSessionTabVisibility(record.sessionId, true, tabId) + database.close() + await rm(legacyAgentSessionStorePath(root), { recursive: true }) +} + function startupRuntime(options: { afterInstall?: () => void; profileChats?: string[] } = {}) { const onError = vi.fn() const runtime = new OrcaRuntimeService() @@ -364,6 +385,26 @@ describe('restoring the chat tabs open at quit', () => { expect(writes.refused - prepared).toBe(2) }) + // The profile never lists a Claude chat, so only the tab row that chat left brings it back. + it("restores a chat opened while the copy was owed beside the profile's chats", async () => { + const owed = chatRecord(OWED) + await seedChatOpenedWhileOwed(owed, 'tab-opened-while-owed') + const records = [chatRecord(CHAT_A, { codex: true })] + await seedProfile(records, { history: [owed, ...records] }) + const { runtime, published } = startupRuntime({ profileChats: [CHAT_A] }) + + await runtime.restoreStructuredAgentSessionTabs() + + expect(published().map((tab) => tab.id)).toEqual([ + `agent-session:${OWED}`, + `agent-session:${CHAT_A}` + ]) + expect((await readPersistedTestAgentSessionStore(root)).sessionTabs).toEqual([ + { tabId: 'tab-opened-while-owed', sessionId: OWED }, + { tabId: `structured-agent-session-${CHAT_A}`, sessionId: CHAT_A } + ]) + }) + it('still lists the chats when that write fails, and leaves the index absent', async () => { const records = legacyChats() await seedProfile(records) diff --git a/src/main/runtime/structured-agent-session-tab-index-seed.ts b/src/main/runtime/structured-agent-session-tab-index-seed.ts index 181d67128a1f..188a419f31e2 100644 --- a/src/main/runtime/structured-agent-session-tab-index-seed.ts +++ b/src/main/runtime/structured-agent-session-tab-index-seed.ts @@ -18,7 +18,8 @@ export async function seedStructuredAgentSessionTabIndex( if (!host?.getPersistedVisibleSessionTabIndex || !host.showSessionTabs) { return } - const listed = new Set(host.getPersistedVisibleSessionTabIndex().sessionIds) + const index = host.getPersistedVisibleSessionTabIndex() + const listed = new Set(index.present ? index.sessionIds : []) const opened = new Set(restored) // In the restore's own order, so the seeded tabs keep the order the profile gave them. const unlisted = [...new Set([...targets, ...restored])].filter( From 895b256c1bce53841363db4a681266b96d8c1f18 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 00:59:31 -0700 Subject: [PATCH 28/30] test(native-chat): pin that a create whose tab index write fails still opens the chat --- .../methods/structured-chat-tab-table.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index 4907d5cb3c0f..c752bddd4582 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -412,3 +412,23 @@ describe('a chat tab over records a newer Orca wrote', () => { ) }) }) + +describe('a chat tab whose restore index cannot be written', () => { + // The index is bookkeeping: the chat is created and its tab opens, but no restart restores it. + it('creates the chat and opens its tab, reporting the index write', async () => { + const failure = new Error('disk I/O error') + vi.spyOn(store, 'setSessionTabVisibility').mockRejectedValue(failure) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const created = await createChat(HOST_TEST_SESSION) + + expect(created).toMatchObject({ ok: true, value: { sessionId: HOST_TEST_SESSION } }) + expect(created.ok ? created.value.tabId : null).toBeUndefined() + expect((await snapshot()).activeTabId).toBe(`agent-session:${HOST_TEST_SESSION}`) + expect(warn).toHaveBeenCalledWith( + '[structured-agent-session] recording an opened chat tab failed', + failure + ) + expect(store.getSessionTabId(HOST_TEST_SESSION)).toBeNull() + }) +}) From 286f5ef39e279062fa04c8dd11995cf78301ac42 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 01:11:06 -0700 Subject: [PATCH 29/30] docs(native-chat): say why restore puts chats opened while the import was owed first --- .../orca-runtime-restore-structured-agent-session-tabs-once.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index f7866529e540..f8845221e66a 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -61,6 +61,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu 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])] From add94320edabbbe092393af1180d17f12142540c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:57:04 -0700 Subject: [PATCH 30/30] test(native-chat): replace a ledger row rather than change it in place in the Send-now rerun test The record store freezes published rows in tests, so setting a row's outcome in place threw; the test now swaps in a changed copy, as its sibling cases do. --- .../structured-agent-session-queued-gate.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index bc4697be6df8..26eaa8b8ab59 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -286,9 +286,10 @@ describe('Send-now rerun', () => { expect(await drafts()).toMatchObject([{ messageId: draftId, state: 'returned' }]) ) // The host died before the Send's answer settled: its ledger row is still pending, so it reruns. - for (const row of store['transactions'].state.operations.values()) { + const operations = store['transactions'].state.operations + for (const [key, row] of operations) { if (row.operationId === operationId) { - row.outcome = { status: 'pending' } + operations.set(key, { ...row, outcome: { status: 'pending' } }) } } const count = (await host.journalSnapshot(SESSION)).submissions.length