diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs index 3991a886ab59..ed3476702777 100644 --- a/config/scripts/journal-replay-retention-benchmark.mjs +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -2,75 +2,86 @@ import assert from 'node:assert/strict' import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, join, resolve } from 'node:path' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' import { build } from 'esbuild' -// Pass a directory containing journal-open.ts and journal-row-table.ts from the base commit. +// Pass the root of a checkout (or `git archive src` extract) of the base commit: each arm is +// bundled whole from its own tree and writes its own fixture, since the two store history differently. const baselineDir = process.argv[2] assert.ok( baselineDir, - 'Usage: node --expose-gc journal-replay-retention-benchmark.mjs BASELINE_DIR' + 'Usage: node --expose-gc journal-replay-retention-benchmark.mjs BASELINE_CHECKOUT_ROOT' ) assert.ok(global.gc, 'Run with --expose-gc to measure live backing memory during replay') const root = fileURLToPath(new URL('../..', import.meta.url)) +const journalSource = './src/main/native-chat/agent-session-journal' +const entries = { + baseline: `export {openAgentSessionJournal} from '${journalSource}/journal-store-factory'; export {replayJournal} from '${journalSource}/journal-open'; export {openJournalDatabase} from '${journalSource}/journal-database'; export {journalDatabaseFile} from '${journalSource}/journal-paths';`, + current: `export {openAgentSessionJournal} from '${journalSource}/journal-store-factory'; export {replayJournal} from '${journalSource}/journal-open'; export {JournalHostDatabase, journalDatabasePath} from '${journalSource}/journal-host-database';` +} +const sourceRoots = { baseline: resolve(baselineDir), current: root } +const identity = { + sessionId: 'benchmark', + workspaceId: 'fixture', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread' } +} const fixture = await mkdtemp(join(tmpdir(), 'orca-journal-replay-bench-')) -try { - const implementations = {} - for (const arm of ['baseline', 'current']) { - const outfile = join(fixture, `${arm}.cjs`) - await build({ - stdin: { - contents: - "export {openAgentSessionJournal} from './src/main/native-chat/agent-session-journal/journal-store-factory'; export {loadJournal} from './src/main/native-chat/agent-session-journal/journal-open'; export {journalDatabaseFile} from './src/main/native-chat/agent-session-journal/journal-paths';", - resolveDir: root - }, - bundle: true, - platform: 'node', - format: 'cjs', - outfile, - plugins: [ - { - name: 'replay-memory-probe', - setup(plugin) { - plugin.onLoad( - { filter: /journal-(?:open|row-table|reducer)\.ts$/ }, - async ({ path }) => { - const leaf = basename(path) - let source = await readFile( - arm === 'baseline' && leaf !== 'journal-reducer.ts' - ? join(baselineDir, leaf) - : path, - 'utf8' - ) - if (leaf === 'journal-reducer.ts') { - const marker = - 'export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {' - assert.ok(source.includes(marker)) - source = source.replace( - marker, - `${marker}\nglobalThis.__replayMemoryProbe?.(row.seq);` - ) - } - return { contents: source, loader: 'ts', resolveDir: dirname(path) } - } - ) - } +// Released in `finally`, newest first: an open SQLite handle blocks the fixture's removal on Windows. +const releases = [] + +async function bundle(arm) { + const outfile = join(fixture, `${arm}.cjs`) + await build({ + stdin: { contents: entries[arm], resolveDir: sourceRoots[arm] }, + bundle: true, + platform: 'node', + format: 'cjs', + outfile, + // A bare base checkout has no node_modules of its own. + nodePaths: [join(root, 'node_modules')], + plugins: [ + { + name: 'replay-memory-probe', + setup(plugin) { + plugin.onLoad({ filter: /journal-reducer\.ts$/ }, async ({ path }) => { + const marker = + 'export function applyJournalRow(state: JournalReducerState, row: JournalRow): void {' + const source = await readFile(path, 'utf8') + assert.ok(source.includes(marker), `${basename(path)} lost the probe marker`) + return { + contents: source.replace( + marker, + `${marker}\nglobalThis.__replayMemoryProbe?.(row.seq);` + ), + loader: 'ts', + resolveDir: dirname(path) + } + }) } - ] - }) - implementations[arm] = createRequire(import.meta.url)(outfile) - } - const identity = { - sessionId: 'benchmark', - workspaceId: 'fixture', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread' } + } + ] + }) + return createRequire(import.meta.url)(outfile) +} + +/** One long-revised item, written by the arm's own store; returns the arm's replay of it. */ +async function openArm(arm) { + const implementation = await bundle(arm) + const stateDirectory = join(fixture, arm) + let journal + let database + if (arm === 'baseline') { + journal = await implementation.openAgentSessionJournal({ identity, journalDir: stateDirectory }) + } else { + database = implementation.JournalHostDatabase.open(stateDirectory) + releases.push(() => database.close()) + journal = await implementation.openAgentSessionJournal({ identity, database }) } - const journalDir = join(fixture, 'session') - const journal = await implementations.current.openAgentSessionJournal({ identity, journalDir }) + releases.push(() => journal.close()) const item = { provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 } const text = 'x'.repeat(32768) for (let revision = 0; revision < 2000; revision++) { @@ -84,11 +95,29 @@ try { { fence: 1 } ) } - await journal.close() + await releases.pop()() + if (arm === 'current') { + return { + path: implementation.journalDatabasePath(stateDirectory), + replay: () => implementation.replayJournal(database.db, identity.sessionId) + } + } + const path = implementation.journalDatabaseFile(stateDirectory) + const opened = implementation.openJournalDatabase(path) + releases.push(() => opened.db.close()) + return { + path, + // The base replay takes the connection's read-only flag before the chat. + replay: () => implementation.replayJournal(opened.db, opened.readOnly, identity.sessionId) + } +} + +try { + const arms = { baseline: await openArm('baseline'), current: await openArm('current') } for (const arm of ['baseline', 'current', 'current', 'baseline']) { global.gc() const start = performance.now() - let loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + let loaded = arms[arm].replay() const ms = performance.now() - start assert.equal(loaded.state.items.size, 1) assert.equal([...loaded.state.items.values()][0].revision, 2000) @@ -103,7 +132,7 @@ try { global.gc() peakLiveHeap = Math.max(peakLiveHeap, process.memoryUsage().heapUsed) } - loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + loaded = arms[arm].replay() delete globalThis.__replayMemoryProbe assert.equal(loaded.state.items.size, 1) loaded = null @@ -111,12 +140,19 @@ try { JSON.stringify({ arm, ms, - databaseBytes: (await stat(implementations[arm].journalDatabaseFile(journalDir))).size, + databaseBytes: (await stat(arms[arm].path)).size, peakLiveHeapDelta: peakLiveHeap - initialHeap }) ) } } finally { delete globalThis.__replayMemoryProbe + for (const release of releases.toReversed()) { + try { + await release() + } catch (error) { + console.error('[journal-replay-retention-benchmark] release failed', error) + } + } await rm(fixture, { recursive: true, force: true }) } diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index bca14e293f75..acff99a6a2e1 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -15,8 +15,10 @@ import { agentSessionWriteNoticeParts } from '../../../src/shared/agent-session-refusal-notice' import { - agentSessionRpcErrorFailure, + agentSessionRefusalFailure, + agentSessionThrownFailure, agentSessionWriteKindForMethod, + readAgentSessionErrorRefusal, type AgentSessionWriteKind } from '../../../src/shared/agent-session-write-failure' import { structuredSessionOperationId } from './structured-session-operation-id' @@ -51,12 +53,36 @@ export type StructuredAgentSessionMutate = ( class AgentSessionRpcResponseError extends Error { constructor( readonly code: string, - message: string + message: string, + /** A thrown refusal's reason rides here; its message is only the bare code. */ + readonly data?: unknown ) { super(message) } } +/** A failed read of a chat's history as the pane shows it, from a thrown error or a stream's error + * frame (`{ message, error }`): a thrown refusal's message is its bare code, so its words come + * from the refusal in the error's data. */ +export function agentSessionReadFailureText(failure: unknown): string { + const refusal = readAgentSessionErrorRefusal( + typeof failure === 'object' && failure !== null && 'error' in failure ? failure.error : failure + ) + if (refusal) { + return agentSessionWriteNoticeEnglish( + agentSessionWriteNoticeParts(agentSessionRefusalFailure(refusal), 'read-history') + ) + } + if (failure instanceof Error) { + return failure.message + } + return typeof failure === 'object' && failure !== null + ? 'message' in failure + ? String(failure.message ?? '') + : '' + : String(failure) +} + export async function callAgentSession( client: RpcClient, method: string, @@ -70,7 +96,11 @@ export async function callAgentSession( ...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {}) }) if (!response.ok) { - throw new AgentSessionRpcResponseError(response.error.code, response.error.message) + throw new AgentSessionRpcResponseError( + response.error.code, + response.error.message, + response.error.data + ) } return response.result as TResult } @@ -186,7 +216,9 @@ export async function requestStructuredAgentSessionMutation(args: { } } catch (error) { const answered = - error instanceof AgentSessionRpcResponseError ? agentSessionRpcErrorFailure(error.code) : null + error instanceof AgentSessionRpcResponseError + ? agentSessionThrownFailure(error, error.code) + : null if (answered && answered.kind !== 'unconfirmed') { // The host turned the request away before running it; its text is written for a log. return { diff --git a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts new file mode 100644 index 000000000000..065845640b3c --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import { + agentSessionReadFailureText, + callAgentSession, + requestStructuredAgentSessionMutation +} from './mobile-structured-agent-session-rpc' + +// As `mapRuntimeError` sends a thrown refusal (pinned in `src/main/runtime/rpc/errors.test.ts`): +// its message is the bare code, and its reason rides in data. +const THROWN_JOURNAL_REFUSAL = { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } +} + +function refusingClient(): RpcClient { + const sendRequest = async () => ({ id: 'req-1', ok: false, error: THROWN_JOURNAL_REFUSAL }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the paths under test reach only `sendRequest`. + return { sendRequest } as unknown as RpcClient +} + +describe('a refusal the host threw', () => { + it("reads a chat's history failure in the refusal's words, from a stream or a request", async () => { + const words = 'Unable to load this chat.' + // The stream's error frame, as the RPC client hands it over. + expect( + agentSessionReadFailureText({ + type: 'error', + message: THROWN_JOURNAL_REFUSAL.message, + error: THROWN_JOURNAL_REFUSAL + }) + ).toBe(words) + const thrown = await callAgentSession(refusingClient(), 'agentSession.history', {}).catch( + (error: unknown) => error + ) + expect(agentSessionReadFailureText(thrown)).toBe(words) + expect(agentSessionReadFailureText({ type: 'error', message: 'Connection interrupted' })).toBe( + 'Connection interrupted' + ) + }) + + it("fails a write with the refusal's words, not as unconfirmed", async () => { + const result = await requestStructuredAgentSessionMutation({ + client: refusingClient(), + method: 'agentSession.cancel', + fingerprintMethod: 'agentSession.cancel', + sessionId: 'session-1', + expectedRuntimeFence: 3, + fields: { turnId: 'turn-1' }, + clientOperationId: `1900000000000-${'c'.repeat(32)}` + }) + + expect(result).toEqual({ + status: 'failed', + message: "Unable to load this chat. The agent wasn't stopped." + }) + }) +}) + +describe('a write refused on a journal a newer Orca wrote', () => { + // As the host answers it (pinned in `journal-open-failure.test.ts`). + const newerOrcaClient = (): RpcClient => { + const sendRequest = async () => ({ + id: 'req-1', + ok: true, + result: { + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + } + } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the paths under test reach only `sendRequest`. + return { sendRequest } as unknown as RpcClient + } + + it.each([ + [ + 'agentSession.send', + { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } }, + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + ], + [ + 'agentSession.cancel', + { turnId: 'turn-1' }, + "Chats were saved by a newer Orca. The agent wasn't stopped. Update Orca to keep using them." + ] + ] as const)('says to update Orca for %s', async (method, fields, message) => { + const result = await requestStructuredAgentSessionMutation({ + client: newerOrcaClient(), + method, + fingerprintMethod: method, + sessionId: 'session-1', + expectedRuntimeFence: 3, + fields, + clientOperationId: `1900000000000-${'d'.repeat(32)}` + }) + + expect(result).toEqual({ + status: 'refused', + code: 'agent_session_journal_unreadable', + message + }) + }) +}) diff --git a/mobile/src/session/use-mobile-structured-agent-state.ts b/mobile/src/session/use-mobile-structured-agent-state.ts index d2b6d1a50629..3dcdc76a8b56 100644 --- a/mobile/src/session/use-mobile-structured-agent-state.ts +++ b/mobile/src/session/use-mobile-structured-agent-state.ts @@ -13,7 +13,10 @@ import { type StructuredAgentSessionState } from '../../../src/shared/structured-agent-session-reducer' import type { RpcClient } from '../transport/rpc-client' -import { callAgentSession } from './mobile-structured-agent-session-rpc' +import { + agentSessionReadFailureText, + callAgentSession +} from './mobile-structured-agent-session-rpc' const MAX_RETAINED_SESSION_STATES = 32 /** Bounded so a busy stream cannot turn one Load-earlier tap into an endless read chain. */ @@ -134,7 +137,7 @@ export function useMobileStructuredAgentState(args: { }) const endStream = openTranscriptAfterHold(client, sessionId, held, (raw) => { if (typeof raw === 'object' && raw !== null && 'type' in raw && raw.type === 'error') { - apply({ type: 'error', message: 'message' in raw ? String(raw.message ?? '') : '' }) + apply({ type: 'error', message: agentSessionReadFailureText(raw) }) return } if (isSubscribeEvent(raw)) { @@ -199,7 +202,7 @@ export function useMobileStructuredAgentState(args: { })() .catch((error: unknown) => { if (isCurrentRead()) { - apply({ type: 'error', message: error instanceof Error ? error.message : String(error) }) + apply({ type: 'error', message: agentSessionReadFailureText(error) }) } }) .finally(() => { diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 03c84f023c60..67624f612bc4 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -2,6 +2,7 @@ import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease- import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types' import type { AiVaultPrepareSessionResumeArgs } from '../../shared/ai-vault-resume-preparation' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' import { listStructuredProviderSessionOwnership, type StructuredProviderSessionOwnership @@ -53,7 +54,8 @@ export async function assertLegacyAiVaultResumeCommandAllowed( if (!isPotentialStructuredResumeCommand(command)) { return } - await ensureHost() + // A terminal command is not a chat: with chats refused here there is no ownership to check. + await ensureStructuredAgentSessionHostUnlessRefused(ensureHost) const host = getStructuredAgentSessionHost() if (!host) { return 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 fc5d234ec877..30d814dcfa4e 100644 --- a/src/main/claude/claude-api-retry-idle-sweep.test.ts +++ b/src/main/claude/claude-api-retry-idle-sweep.test.ts @@ -17,6 +17,7 @@ import { resetHostTestOperationIds } from '../native-chat/agent-session-wire/structured-agent-session-host-test-data' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const SWEEP_MS = 5 const RETRY_GAP_MS = 10 * 60_000 @@ -81,7 +82,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', idleSweep: { intervalMs: SWEEP_MS, idleMs: STRUCTURED_AGENT_SESSION_IDLE_MS }, diff --git a/src/main/claude/claude-api-retry-row.test.ts b/src/main/claude/claude-api-retry-row.test.ts index becadff90d3c..a1c8da60cb7d 100644 --- a/src/main/claude/claude-api-retry-row.test.ts +++ b/src/main/claude/claude-api-retry-row.test.ts @@ -11,6 +11,7 @@ import { openAgentSessionJournal } from '../native-chat/agent-session-journal/jo import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { unhandledProviderFrameJournalItem } from '../native-chat/agent-session-wire/unhandled-provider-frame' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -49,7 +50,7 @@ afterEach(async () => { async function statusRowsFor(frames: Record[]) { const journal = await openAgentSessionJournal({ identity: IDENTITY, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) diff --git a/src/main/claude/claude-context-usage-restart.test.ts b/src/main/claude/claude-context-usage-restart.test.ts index cb0dff927c50..4359799272e4 100644 --- a/src/main/claude/claude-context-usage-restart.test.ts +++ b/src/main/claude/claude-context-usage-restart.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { selectStructuredAgentContextUsage } from '../../shared/structured-agent-session-context-usage' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { settleStaleStructuredAgentSessionState } from '../native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' @@ -106,7 +106,7 @@ async function openJournal(): Promise { providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } }, now: () => 9_000, - journalDir: join(root, 'orca-session') + stateDirectory: join(root, 'orca-session') }) } diff --git a/src/main/claude/claude-context-usage-unloaded-turn.test.ts b/src/main/claude/claude-context-usage-unloaded-turn.test.ts index b562c839efdb..ed76a788c186 100644 --- a/src/main/claude/claude-context-usage-unloaded-turn.test.ts +++ b/src/main/claude/claude-context-usage-unloaded-turn.test.ts @@ -14,7 +14,7 @@ import { type StructuredAgentSessionState } from '../../shared/structured-agent-session-reducer' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' @@ -51,7 +51,7 @@ async function openJournal(): Promise { providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: null } }, now: () => 9_000, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) } diff --git a/src/main/claude/claude-stream-json-connection.test.ts b/src/main/claude/claude-stream-json-connection.test.ts index e3682f033064..7d198c5a5de5 100644 --- a/src/main/claude/claude-stream-json-connection.test.ts +++ b/src/main/claude/claude-stream-json-connection.test.ts @@ -20,6 +20,7 @@ import { createClaudeJournalTranslator } from './claude-structured-journal-trans import { readClaudeStructuredSessionOptions } from './claude-structured-session-options' import type { ClaudeSession } from './claude-structured-session-state' import { CLAUDE_STRUCTURED_BASE_OPTIONS } from './claude-structured-launch-resolution' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' // These drive the real SDK against the scripted fake CLI, so every assertion is // about the environment, argv and frames a real child actually saw. @@ -380,7 +381,7 @@ describe('Claude stream-json connection', () => { agent: 'claude', providerHandle: { kind: 'claude', sessionId: SESSION_ID, leafUuid: 'leaf-1' } }, - journalDir: join(scenario.cwd, 'journal'), + database: openTestJournalHostDatabase(join(scenario.cwd, 'journal')), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) diff --git a/src/main/claude/claude-structured-journal-translation.test.ts b/src/main/claude/claude-structured-journal-translation.test.ts index d868b3816c4b..1b03881e471b 100644 --- a/src/main/claude/claude-structured-journal-translation.test.ts +++ b/src/main/claude/claude-structured-journal-translation.test.ts @@ -22,6 +22,7 @@ import { import type { ClaudePendingPrompt } from './claude-structured-prompt-replies' import { readAgentJournalTurn } from '../../shared/agent-session-turn-record' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' function sinkState() { const items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] = [] @@ -253,7 +254,7 @@ describe('Claude structured journal translation', () => { it('journals a count-to-200 stream as one assistant item carrying the complete reply', async () => { const journal = await openAgentSessionJournal({ identity: JOURNAL_IDENTITY, - journalDir: journalRoot, + database: openTestJournalHostDatabase(journalRoot), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) @@ -312,7 +313,7 @@ describe('Claude structured journal translation', () => { it('restores a cancelled prompt as terminal history after reopening the journal', async () => { const journal = await openAgentSessionJournal({ identity: JOURNAL_IDENTITY, - journalDir: journalRoot, + database: openTestJournalHostDatabase(journalRoot), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) @@ -341,7 +342,7 @@ describe('Claude structured journal translation', () => { const reopened = await openAgentSessionJournal({ identity: JOURNAL_IDENTITY, - journalDir: journalRoot, + database: openTestJournalHostDatabase(journalRoot), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-2' }) diff --git a/src/main/claude/claude-structured-provider-fallback.test.ts b/src/main/claude/claude-structured-provider-fallback.test.ts index e8b27114da45..a3a47c8bea8a 100644 --- a/src/main/claude/claude-structured-provider-fallback.test.ts +++ b/src/main/claude/claude-structured-provider-fallback.test.ts @@ -10,6 +10,7 @@ import { openAgentSessionJournal } from '../native-chat/agent-session-journal/jo import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createClaudeJournalTranslator } from './claude-structured-journal-translation' import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -75,7 +76,7 @@ describe('Claude provider fallback', () => { it('keeps provider-fallback rows distinct across acquisitions', async () => { const journal = await openAgentSessionJournal({ identity: IDENTITY, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_700_000_000_000, mintEpoch: () => 'epoch-1' }) diff --git a/src/main/codex/codex-structured-journal-goal-revision.test.ts b/src/main/codex/codex-structured-journal-goal-revision.test.ts index 2fbe33ab901f..4f8276005770 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -10,7 +10,7 @@ import type { import { currentAgentSessionThreadGoal } from '../../shared/agent-session-thread-goal' import type { AgentSessionHistoryPage } from '../../shared/agent-session-wire' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../native-chat/agent-session-wire/agent-session-history-page' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { CodexJournalGoals } from './codex-structured-journal-goals' @@ -90,7 +90,7 @@ function journalSink(journal: AgentSessionJournal) { describe('codex goal accounting revisions', () => { it('revises the goal row in place: one visible row, pinned sequence, fresh counters', async () => { root = await mkdtemp(join(tmpdir(), 'orca-goal-revision-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) await journal.appendItem( { provider: 'orca', clientMessageId: 'earlier' }, { kind: 'status', text: 'Context compacted' }, @@ -159,7 +159,7 @@ describe('codex goal accounting revisions', () => { it('refreshes the row from a resume snapshot of the same goal', async () => { root = await mkdtemp(join(tmpdir(), 'orca-goal-resume-revision-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const first = journalSink(journal) const prior = new CodexJournalGoals(first.sink, () => ({ turnScope: AGENT_JOURNAL_THREAD_SCOPE diff --git a/src/main/codex/codex-structured-journal-subagent-readers.test.ts b/src/main/codex/codex-structured-journal-subagent-readers.test.ts index 7b7266345e71..69e28d67ec4b 100644 --- a/src/main/codex/codex-structured-journal-subagent-readers.test.ts +++ b/src/main/codex/codex-structured-journal-subagent-readers.test.ts @@ -19,6 +19,7 @@ import type { AgentSessionJournal } from '../native-chat/agent-session-journal/j import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' import type { CodexThreadItem } from './codex-thread-item-identity' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const SESSION = 'session-codex-children' const PARENT = 'thread-parent' @@ -42,7 +43,7 @@ async function openJournal(root: string): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: PARENT } }, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_000 }) } diff --git a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts index 6fb589caf4e0..f9714bbe0625 100644 --- a/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts +++ b/src/main/codex/codex-structured-journal-translation-turn-lifecycle.test.ts @@ -11,7 +11,7 @@ import { readAgentJournalTurn, readAgentJournalTurnOutcome } from '../../shared/agent-session-turn-record' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink, type StructuredAgentSessionEventSink @@ -221,7 +221,7 @@ describe('codex turn lifecycle rows', () => { providerHandle: { kind: 'codex', threadId: THREAD_ID } }, now: () => 9_000, - journalDir: join(root, SESSION_ID) + stateDirectory: join(root, SESSION_ID) }) const deferred = createDeferredStructuredAgentSessionEventSink() const translator = createCodexJournalTranslator({ diff --git a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts index 4e3caaa8bc00..7011cfa0302f 100644 --- a/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts +++ b/src/main/codex/codex-structured-journal-turn-settles-once-journal.test.ts @@ -10,7 +10,7 @@ import { completedStructuredAgentTurnSeconds, selectStructuredAgentRunningTurnTiming } from '../../shared/structured-agent-session-turn-timing' -import { openAgentSessionJournal } from '../native-chat/agent-session-journal/journal-store-factory' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink' import { createCodexJournalTranslator } from './codex-structured-journal-translation' @@ -18,6 +18,7 @@ const SESSION = 'session-codex-failed-turn' const THREAD = 'thread-abc' const TURN = 'turn-1' +const journals = createTrackedJournalOpener() const cleanups: (() => Promise)[] = [] afterEach(async () => { for (const cleanup of cleanups.splice(0)) { @@ -27,7 +28,7 @@ afterEach(async () => { async function session() { const root = await mkdtemp(join(tmpdir(), 'orca-codex-failed-turn-')) - const journal = await openAgentSessionJournal({ + const journal = await journals.open({ identity: { sessionId: SESSION, workspaceId: 'workspace-1', @@ -35,14 +36,14 @@ async function session() { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => 1_000 }) const deferred = createDeferredStructuredAgentSessionEventSink() deferred.bind({ journal, fence: 1, publish: () => {} }) cleanups.push(async () => { deferred.close() - await journal.close() + await journals.closeAll() await rm(root, { recursive: true, force: true }) }) const translator = createCodexJournalTranslator({ diff --git a/src/main/codex/codex-structured-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index 59a36dbdf67f..15f82b824343 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -34,6 +34,7 @@ import { projectStructuredQuestionMessages, structuredQuestionTranscript } from '../../renderer/src/components/native-chat/structured-agent-question-projection' +import { openTestJournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -93,7 +94,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', // Every write lands on its own millisecond, as it does live. diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts index 56067ebc3e9c..4e67441ea343 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.test.ts @@ -394,8 +394,15 @@ vi.mock('../native-chat', () => ({ registerNativeChatHandlers: registerNativeChatHandlersMock })) +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { recordStructuredAgentSessionHostInstallRefusal } from '../../runtime/structured-agent-session-host-refusal' import { registerCoreHandlers } from './register-core-handlers' +let registeredAiVaultOptions: { + ensureStructuredSessionOwnership: () => Promise +} +let registeredRuntime: { ensureStructuredAgentSessionHost: ReturnType } | undefined + describe('registerCoreHandlers', () => { beforeEach(() => { getPathMock.mockReset() @@ -464,7 +471,11 @@ describe('registerCoreHandlers', () => { it('passes the store through to handler registrars that need it', async () => { const store = { marker: 'store' } - const runtime = { marker: 'runtime', getAgentBrowserBridge: () => null } + const runtime = { + marker: 'runtime', + getAgentBrowserBridge: () => null, + ensureStructuredAgentSessionHost: vi.fn() + } const stats = { marker: 'stats' } const claudeUsage = { marker: 'claudeUsage' } const codexUsage = { marker: 'codexUsage' } @@ -500,6 +511,9 @@ describe('registerCoreHandlers', () => { const aiVaultOptions = registerAiVaultHandlersMock.mock.calls[0]?.[0] expect(aiVaultOptions).toBeDefined() + // Registration happens once per module: later tests reach these handlers through here. + registeredAiVaultOptions = aiVaultOptions + registeredRuntime = runtime callRuntimeEnvironmentMock.mockResolvedValueOnce({ ok: true, @@ -639,6 +653,31 @@ describe('registerCoreHandlers', () => { ) }) + // Session history and terminal resume are not chats: the refusal chats get leaves them no host + // to check, and any other host failure still fails them. + it('serves session history while chats are refused', async () => { + const aiVaultOptions = registeredAiVaultOptions + expect(aiVaultOptions).toBeDefined() + const refusal = agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalCorrupt' }, + 'Unable to load this chat.' + ) + recordStructuredAgentSessionHostInstallRefusal(refusal) + try { + registeredRuntime?.ensureStructuredAgentSessionHost.mockRejectedValueOnce(refusal) + await expect(aiVaultOptions.ensureStructuredSessionOwnership()).resolves.toBeUndefined() + registeredRuntime?.ensureStructuredAgentSessionHost.mockRejectedValueOnce( + new Error('the record store would not open') + ) + await expect(aiVaultOptions.ensureStructuredSessionOwnership()).rejects.toThrow( + 'the record store would not open' + ) + } finally { + recordStructuredAgentSessionHostInstallRefusal(null) + } + }) + it('only registers IPC handlers once but always updates web contents id', () => { // The first test already called registerCoreHandlers, so the module-level // guard is now set. beforeEach reset all mocks, so call counts are 0. diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index e23373c803c0..d3a647cafd71 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -92,6 +92,7 @@ import { scanRuntimeAiVaultSessions } from '../../ai-vault/runtime-session-scanner' import { callRuntimeSessionSearch } from '../../ai-vault/runtime-session-search-call' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../../runtime/structured-agent-session-host-refusal' import type { PluginService } from '../../plugins/plugin-service' import type { PluginMarketplaceHandlerServices } from '../plugin-marketplaces' @@ -223,7 +224,11 @@ export function registerCoreHandlers( callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params) }) registerAiVaultHandlers({ - ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(), + // Session history and terminal resume are not chats; a refused host leaves nothing to check. + ensureStructuredSessionOwnership: () => + ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/native-chat/agent-session-journal/journal-close-retry.ts b/src/main/native-chat/agent-session-journal/journal-close-retry.ts deleted file mode 100644 index e73be0a59b8c..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-close-retry.ts +++ /dev/null @@ -1,68 +0,0 @@ -// Where a journal goes when its close REJECTS. -// -// `AgentSessionJournal.close()` is deliberately retryable: a rejection does not -// release the handle, and a second call is a real second attempt. Every caller -// that did `close().catch(() => undefined)` and then threw or overwrote its map -// entry defeated that contract — the store became unreachable with its SQLite -// connection still open. On POSIX that is a silent leak; on Windows the open -// handle blocks renaming or removing the journal directory outright. -// -// So a rejected close hands the journal here instead of dropping it, and host -// teardown retries everything this holds. Retention is bounded by construction: -// an entry leaves the set the moment its close fulfils, and a journal can be -// retained only once because the set is keyed by identity. - -/** Everything the registry needs; `AgentSessionJournal` satisfies it. */ -export type RetryableJournalClose = { - close: () => Promise - readonly directory: string -} - -export class JournalCloseRetryRegistry { - private readonly retained = new Set() - - /** Directories still held by a journal whose close has not fulfilled. */ - get pendingDirectories(): string[] { - return [...this.retained].map((journal) => journal.directory) - } - - /** - * Close it. Returns true when the handle is actually released; on a rejection - * the journal is RETAINED for `retryAll` and the rejection is returned rather - * than thrown, because every caller of this is already unwinding a different - * failure it must not lose. - */ - async closeOrRetain( - journal: RetryableJournalClose - ): Promise<{ closed: boolean; error?: unknown }> { - try { - await journal.close() - this.retained.delete(journal) - return { closed: true } - } catch (error) { - this.retained.add(journal) - return { closed: false, error } - } - } - - /** Retry every retained close. Ones that fulfil are dropped; ones that reject - * stay retained and their rejections are returned for the caller to report. */ - async retryAll(): Promise { - const entries = [...this.retained] - const failures: unknown[] = [] - for (const journal of entries) { - const result = await this.closeOrRetain(journal) - if (!result.closed) { - failures.push(result.error) - } - } - return failures - } -} - -/** - * Process-wide, because ownership of these handles is process-wide: the attach - * path, the recovery wrapper and runtime teardown are separate call trees that - * must all be able to reach the same orphan. - */ -export const agentSessionJournalCloseRetries = new JournalCloseRetryRegistry() diff --git a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts index c44390b738eb..a5e7506d1bdc 100644 --- a/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-corruption-repair.test.ts @@ -18,12 +18,16 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' -import { openJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' import { parseJournalRow, type JournalRow } from './journal-row-schema' -import { loadJournal } from './journal-open' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal, + liveTestJournalRows, + updateTestJournalRowJson, + deleteTestJournalRow +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -53,7 +57,7 @@ function body(value: string): AgentJournalItemBody { function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides @@ -61,11 +65,11 @@ function open(overrides: Partial[0]> } async function withJournalDatabase(run: (db: Database.Database) => void): Promise { - const opened = openJournalDatabase(journalDatabaseFile(root)) + const opened = openTestJournalHostDatabase(root) try { run(opened.db) } finally { - opened.db.close() + opened.close() } } @@ -73,10 +77,8 @@ async function withJournalDatabase(run: (db: Database.Database) => void): Promis function firstLiveRow(): Promise { let row: JournalRow | null = null return withJournalDatabase((db) => { - const stored = db.prepare('SELECT row_json FROM journal_rows ORDER BY seq LIMIT 1').get() as - | { row_json: string } - | undefined - const parsed = stored ? parseJournalRow(stored.row_json) : null + const stored = liveTestJournalRows(db, IDENTITY.sessionId)[0] + const parsed = stored ? parseJournalRow(stored.rowJson) : null row = parsed?.ok ? parsed.row : null }).then(() => row) } @@ -84,9 +86,7 @@ function firstLiveRow(): Promise { function liveSequences(): Promise { let sequences: number[] = [] return withJournalDatabase((db) => { - sequences = ( - db.prepare('SELECT seq FROM journal_rows ORDER BY seq').all() as { seq: number }[] - ).map((row) => row.seq) + sequences = liveTestJournalRows(db, IDENTITY.sessionId).map((row) => row.seq) }).then(() => sequences) } @@ -117,7 +117,7 @@ describe('a malformed row', () => { }) await journal.close() await withJournalDatabase((db) => { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('{"not":"a row"}', 3) + updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '{"not":"a row"}') }) const reopened = await open() @@ -138,7 +138,7 @@ describe('a malformed row', () => { }) await journal.close() await withJournalDatabase((db) => { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) + updateTestJournalRowJson(db, IDENTITY.sessionId, 2, '}{') }) const reopened = await open() @@ -166,7 +166,7 @@ describe('a sequence gap', () => { // Sequence 1 is the epoch row, so the items occupy 2..6. Removing 4 leaves // 5 and 6 valid but unanchored. await withJournalDatabase((db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(4) + deleteTestJournalRow(db, IDENTITY.sessionId, 4) }) const reopened = await open() @@ -189,12 +189,12 @@ describe('a sequence gap', () => { } await journal.close() await withJournalDatabase((db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(4) + deleteTestJournalRow(db, IDENTITY.sessionId, 4) }) const repaired = await open() await repaired.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) // Same policy the emptied-epoch repair takes: a session that writes into the // epoch owns it, and a later import must not replace rows the user has seen. @@ -204,7 +204,7 @@ describe('a sequence gap', () => { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await writable.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) // The disclosure is the repair talking about itself, not the session writing: @@ -219,13 +219,13 @@ describe('a sequence gap', () => { } await journal.close() await withJournalDatabase((db) => { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 3) + updateTestJournalRowJson(db, IDENTITY.sessionId, 3, '}{') }) const repaired = await open() expect(repaired.repair.malformedRows).toBe(1) await repaired.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) }) }) @@ -257,7 +257,7 @@ describe('a missing epoch row', () => { }) await journal.close() await withJournalDatabase((db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + deleteTestJournalRow(db, IDENTITY.sessionId, 1) }) const reopened = await open() @@ -282,12 +282,12 @@ describe('a missing epoch row', () => { }) await journal.close() await withJournalDatabase((db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + deleteTestJournalRow(db, IDENTITY.sessionId, 1) }) const repaired = await open() await repaired.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) // A session that writes into the epoch owns it: its own rows are not a // repair placeholder, and a later import must not replace them. @@ -297,6 +297,6 @@ describe('a missing epoch row', () => { turnScope: AGENT_JOURNAL_THREAD_SCOPE }) await writable.close() - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: false }) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts index 17fb2b1eb499..a6821cf8427b 100644 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts @@ -25,7 +25,7 @@ import { type ProviderHistoryItem, type ProviderHistoryWindow } from './journal-submission-reconciler' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -63,7 +63,7 @@ const journals = createTrackedJournalOpener() async function open() { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}` }) 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 37675fbcd8e3..ffad529cb4b4 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 @@ -1,16 +1,17 @@ -// Table shape for one session's journal database. +// Table shape for the host's one chat journal database. // -// `journal_rows` is the append-only log; `journal_sessions` is the derived -// projection, upserted in the SAME transaction as every row insert so the live -// epoch and the rows that belong to it can never disagree. `journal_repairs` -// carries at most one row per session: the standing demand for a rebuild a -// partial repair leaves behind (see journal-repair-marker.ts). +// `journal_rows` is every chat's append-only log, keyed `(session_id, epoch, seq)`. +// `journal_sessions` names each chat's live epoch, and is written only when that epoch changes, so +// an append is one INSERT. `journal_repairs` carries at most one row per chat: the standing demand +// 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. -/** DB shape version, carried in `PRAGMA user_version`. Independent of the row - * body version (`JournalRow.v`): a newer build can change either alone. - * v2 added `journal_repairs`; a build without it would replay a partially - * repaired journal as clean, so it must latch read-only rather than write. */ -export const JOURNAL_DB_SCHEMA_VERSION = 2 +/** 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 export function createJournalTablesSql(): string { return ` @@ -23,9 +24,9 @@ CREATE TABLE IF NOT EXISTS journal_rows ( PRIMARY KEY (session_id, epoch, seq) ); CREATE TABLE IF NOT EXISTS journal_sessions ( - session_id TEXT PRIMARY KEY, - epoch TEXT NOT NULL, - updated_at INTEGER NOT NULL + session_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + epoch TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS journal_repairs ( session_id TEXT PRIMARY KEY, @@ -33,5 +34,15 @@ CREATE TABLE IF NOT EXISTS journal_repairs ( content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS journal_imports ( + session_id TEXT PRIMARY KEY, + epoch TEXT NOT NULL, + tip INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS journal_set_aside ( + session_id TEXT PRIMARY KEY, + epoch TEXT NOT NULL, + tip INTEGER 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 358c42c8d555..0b479cd9adf6 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 @@ -1,23 +1,27 @@ -import { mkdtemp, rm, stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, stat } 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 { JOURNAL_BUSY_TIMEOUT_MS, + JOURNAL_SIZE_LIMIT_BYTES, journalPragmaNumber, openJournalDatabase } from './journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalDatabaseFile } from './journal-paths' +import { journalDatabasePath } from './journal-host-database' +import { JournalUnreleasedSchemaError } from './journal-open-failure' import { - deleteAllJournalRows, + deleteJournalEpochRows, deleteJournalRowSuffix, + deleteUnpublishedJournalRows, insertJournalRow, - readJournalEpochRows, + iterateJournalEpochRows, + publishJournalSessionEpoch, readJournalRowsAfter, - readJournalSessionEpoch, - upsertJournalSessionRow + readJournalSessionEpoch } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' @@ -38,9 +42,21 @@ function epochRow(seq: number, epoch = 'epoch-1'): JournalRow { } } +const SESSION = { sessionId: 'session-1', workspaceId: 'ws-1' } + +function rowsOf(db: Database.Database, sessionId: string, epoch: string): number[] { + return [...iterateJournalEpochRows(db, sessionId, epoch)].map((row) => row.seq) +} + +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) - dbPath = journalDatabaseFile(root) + dbPath = journalDatabasePath(root) }) afterEach(async () => { @@ -48,53 +64,63 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -describe('journal database open', () => { - it('creates both tables and reads back every load-bearing pragma', () => { - const opened = openJournalDatabase(dbPath) +describe('the host journal database open', () => { + it('creates every table and reads back every load-bearing pragma', () => { + const db = openJournalDatabase(dbPath).db try { - const tables = opened.db + const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") .all() .map((entry) => (entry as { name: string }).name) - expect(tables).toContain('journal_rows') - expect(tables).toContain('journal_sessions') - expect(opened.db.pragma('journal_mode', { simple: true })).toBe('wal') - expect(journalPragmaNumber(opened.db, 'synchronous')).toBe(2) - expect(journalPragmaNumber(opened.db, 'busy_timeout')).toBe(JOURNAL_BUSY_TIMEOUT_MS) - expect(journalPragmaNumber(opened.db, 'foreign_keys')).toBe(1) - expect(journalPragmaNumber(opened.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) - expect(opened.readOnly).toBe(false) + expect(tables).toEqual( + expect.arrayContaining([ + 'journal_imports', + 'journal_repairs', + 'journal_rows', + 'journal_sessions', + 'journal_set_aside' + ]) + ) + expect(db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(journalPragmaNumber(db, 'synchronous')).toBe(2) + expect(journalPragmaNumber(db, 'checkpoint_fullfsync')).toBe(1) + expect(journalPragmaNumber(db, 'busy_timeout')).toBe(JOURNAL_BUSY_TIMEOUT_MS) + expect(journalPragmaNumber(db, 'foreign_keys')).toBe(1) + // 2 is INCREMENTAL, which only takes on an empty file before WAL. + expect(journalPragmaNumber(db, 'auto_vacuum')).toBe(2) + expect(journalPragmaNumber(db, 'journal_size_limit')).toBe(JOURNAL_SIZE_LIMIT_BYTES) + expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) } finally { - opened.db.close() + db.close() } }) - it('latches read-only on a future user_version without touching the file', async () => { - const seeded = openJournalDatabase(dbPath) - upsertJournalSessionRow(seeded.db, 'session-1', 'epoch-1', 1) - insertJournalRow(seeded.db, 'session-1', epochRow(1)) - seeded.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) - seeded.db.close() - const before = await stat(dbPath) + // 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 + 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 latched = openJournalDatabase(dbPath) + const opened = openJournalDatabase(dbPath) try { - expect(latched.readOnly).toBe(true) - expect(journalPragmaNumber(latched.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION + 5) - expect(readJournalEpochRows(latched.db, 'session-1', 'epoch-1')).toHaveLength(1) - expect(() => latched.db.exec("INSERT INTO journal_sessions VALUES ('x', 'y', 1)")).toThrow() + expect(opened.readOnly).toBe(true) + expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-1') + expect([...iterateJournalEpochRows(opened.db, 'session-1', 'epoch-1')]).toHaveLength(1) + expect(() => insertJournalRow(opened.db, 'session-1', epochRow(2))).toThrow(/readonly/i) } finally { - latched.db.close() + opened.db.close() } - expect((await stat(dbPath)).size).toBe(before.size) - expect(journalPragmaNumber(openJournalDatabase(dbPath).db, 'user_version')).toBe( - JOURNAL_DB_SCHEMA_VERSION + 5 - ) + + expect(await digest(dbPath)).toBe(before) + // A read-only reader of a WAL file may leave an empty log beside it, never a frame. + expect((await stat(`${dbPath}-wal`).catch(() => null))?.size ?? 0).toBe(0) }) - // Site 1: the raw connection is owned by the open call until it returns. it('closes the raw connection when schema setup throws', async () => { - const failing = join(root, 'nested', 'journal.db') + const failing = join(root, 'nested', 'agent-session-journal.db') expect(() => openJournalDatabase(failing)).toThrow() await expect(stat(`${failing}-wal`)).rejects.toThrow() await expect(rm(root, { recursive: true, force: true })).resolves.toBeUndefined() @@ -103,73 +129,84 @@ describe('journal database open', () => { }) describe('journal row statements', () => { - it('serves replay, resume, discard and suffix truncation from the primary key', () => { - const opened = openJournalDatabase(dbPath) + it('serves replay, resume, suffix truncation and an epoch discard', () => { + const db = openJournalDatabase(dbPath).db try { - const { db } = opened db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { insertJournalRow(db, 'session-1', epochRow(seq)) } - insertJournalRow(db, 'session-1', epochRow(1, 'epoch-old')) - upsertJournalSessionRow(db, 'session-1', 'epoch-1', 42) + insertJournalRow(db, 'session-2', epochRow(1)) + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-1') db.exec('COMMIT') expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1') expect(readJournalSessionEpoch(db, 'absent')).toBeNull() - expect(readJournalEpochRows(db, 'session-1', 'epoch-1').map((row) => row.seq)).toEqual([ - 1, 2, 3, 4, 5 - ]) + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3, 4, 5]) expect(readJournalRowsAfter(db, 'session-1', 'epoch-1', 3).map((row) => row.seq)).toEqual([ 4, 5 ]) - // The rejected suffix leaves `journal_rows`, scoped to its own epoch. expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2) - expect(readJournalEpochRows(db, 'session-1', 'epoch-1').map((row) => row.seq)).toEqual([ - 1, 2, 3 - ]) - expect(readJournalEpochRows(db, 'session-1', 'epoch-old')).toHaveLength(1) + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3]) - deleteAllJournalRows(db) - expect(readJournalEpochRows(db, 'session-1', 'epoch-1')).toHaveLength(0) - expect(readJournalEpochRows(db, 'session-1', 'epoch-old')).toHaveLength(0) - expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1') + // Another chat in the same file is untouched by this chat's discard. + deleteJournalEpochRows(db, 'session-1', 'epoch-1') + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) + expect(rowsOf(db, 'session-2', 'epoch-1')).toEqual([1]) } finally { - opened.db.close() + db.close() } }) - it('refuses a duplicate sequence inside one epoch', () => { - const opened = openJournalDatabase(dbPath) + it('deletes only the rows no pointer names', () => { + const db = openJournalDatabase(dbPath).db try { - insertJournalRow(opened.db, 'session-1', epochRow(1)) - expect(() => insertJournalRow(opened.db, 'session-1', epochRow(1))).toThrow() - insertJournalRow(opened.db, 'session-1', epochRow(1, 'epoch-2')) + insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying')) + insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live')) + insertJournalRow(db, 'session-2', epochRow(1, 'epoch-stale')) + publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-live') + + deleteUnpublishedJournalRows(db, 'session-1') + deleteUnpublishedJournalRows(db, 'session-2') + + expect(rowsOf(db, 'session-1', 'epoch-copying')).toEqual([]) + expect(rowsOf(db, 'session-2', 'epoch-live')).toEqual([1]) + expect(rowsOf(db, 'session-2', 'epoch-stale')).toEqual([]) } finally { - opened.db.close() + db.close() } }) - it('upserts the session projection in place', () => { - const opened = openJournalDatabase(dbPath) + it('refuses a duplicate sequence inside one epoch of one chat', () => { + const db = openJournalDatabase(dbPath).db try { - upsertJournalSessionRow(opened.db, 'session-1', 'epoch-1', 1) - upsertJournalSessionRow(opened.db, 'session-1', 'epoch-2', 2) - expect(readJournalSessionEpoch(opened.db, 'session-1')).toBe('epoch-2') - expect( - opened.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() - ).toMatchObject({ total: 1 }) + insertJournalRow(db, 'session-1', epochRow(1)) + expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() + insertJournalRow(db, 'session-1', epochRow(1, 'epoch-2')) + insertJournalRow(db, 'session-2', epochRow(1)) } finally { - opened.db.close() + db.close() + } + }) + + it('moves the epoch pointer in place', () => { + const db = openJournalDatabase(dbPath).db + try { + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + publishJournalSessionEpoch(db, SESSION, 'epoch-2') + expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-2') + expect(db.prepare('SELECT count(*) AS total FROM journal_sessions').get()).toMatchObject({ + total: 1 + }) + } finally { + db.close() } }) }) describe('schema creation', () => { - // Creating the tables outside the migration transaction left a v2-shaped - // database still reporting version 0, which an older build does not latch - // read-only: it stamps its own version on and writes through v1 SQL. it('publishes no table until the version bump commits with it', () => { const original = Database.prototype.pragma const pragma = vi.spyOn(Database.prototype, 'pragma').mockImplementation(function ( @@ -198,4 +235,25 @@ describe('schema creation', () => { inspected.close() } }) + + // Versions 1 and 2 were written only by unreleased builds; neither is migrated. + it.each([1, 2])('refuses a version %i database without touching the file', async (version) => { + const earlier = new Database(dbPath) + earlier.pragma('journal_mode = WAL') + earlier.exec(` +CREATE TABLE journal_rows (id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, row_json TEXT NOT NULL); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + epoch TEXT NOT NULL, block INTEGER NOT NULL UNIQUE, status_json TEXT, status_seq INTEGER); +INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) + earlier.pragma(`user_version = ${version}`) + earlier.pragma('journal_mode = DELETE') + earlier.close() + const before = await digest(dbPath) + + expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) + expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) + + expect(await digest(dbPath)).toBe(before) + await expect(stat(`${dbPath}-wal`)).rejects.toThrow() + }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index e1c4da54a083..2c01a653af38 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -1,15 +1,20 @@ -// Opening one session's journal database. +// Opening the host's chat journal database. // -// `PRAGMA user_version` is read FIRST, on a connection that has set no -// persistent pragma and run no DDL: a future-schema database must be left -// byte-identical, and `journal_mode = WAL` writes the file header. +// `PRAGMA user_version` is read FIRST, on a connection that has set no persistent pragma and run +// no DDL: a database written by a newer schema must be left byte-identical, and +// `journal_mode = WAL` writes the file header. import Database from '../../sqlite/sync-database' import { hardenSqliteDatabaseFiles } from '../../sqlite/harden-database-files' import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { JournalUnreleasedSchemaError } from './journal-open-failure' import { ensureQueuedMessagesTable } from './queued-message-schema' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 +/** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ +export const JOURNAL_SIZE_LIMIT_BYTES = 32 * 1024 * 1024 +/** Every commit but a first-use copy's batches, which no reader follows until a synced commit. */ +export const JOURNAL_SYNCHRONOUS = 'FULL' export type OpenJournalDatabase = { db: Database.Database @@ -36,16 +41,21 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { } let transferred = false try { - configureJournalPragmas(probe) + if (stored !== 0 && stored < JOURNAL_DB_SCHEMA_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` + ) + } + configureJournalPragmas(probe, stored) createJournalSchema(probe, stored) // Outside `createJournalSchema` 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) - const opened = { db: probe, readOnly: false } transferred = true - return opened + return { db: probe, readOnly: false } } finally { if (!transferred) { probe.close() @@ -53,33 +63,70 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { } } -function configureJournalPragmas(db: Database.Database): void { +function configureJournalPragmas(db: Database.Database, stored: number): void { + if (stored === 0) { + // Only takes on an empty file, and only before WAL, so it is set now: nothing reclaims pages + // yet, but a later pass can free them in bounded steps rather than a full VACUUM. + db.pragma('auto_vacuum = INCREMENTAL') + } db.pragma('journal_mode = WAL') db.pragma(`busy_timeout = ${JOURNAL_BUSY_TIMEOUT_MS}`) db.pragma('foreign_keys = ON') - // Why FULL rather than the house NORMAL: the write-ahead submission row must - // survive a power loss before the adapter dispatches anything, and NORMAL in - // WAL mode does not fsync at commit. - db.pragma('synchronous = FULL') + // Why FULL rather than the house NORMAL: NORMAL in WAL mode does not fsync at commit, and the + // write-ahead submission row must be on disk before the adapter dispatches anything. FULL alone + // does not survive power loss on macOS, whose fsync leaves the drive cache unflushed; checkpoint + // fullfsync makes each checkpoint use F_FULLFSYNC (a no-op elsewhere). + db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) + db.pragma('checkpoint_fullfsync = ON') + db.pragma(`journal_size_limit = ${JOURNAL_SIZE_LIMIT_BYTES}`) } /** - * 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: it stamped its own version on and wrote - * through SQL for a schema it did not have. + * Table creation and the `user_version` bump are ONE transaction. Creating the tables first left + * a shaped database still reporting version 0, which an older build does not latch read-only. */ function createJournalSchema(db: Database.Database, stored: number): void { if (stored >= JOURNAL_DB_SCHEMA_VERSION) { return } - db.exec('BEGIN IMMEDIATE') - try { + runJournalTransaction(db, () => { db.exec(createJournalTablesSql()) db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION}`) + }) +} + +/** + * One IMMEDIATE transaction, its COMMIT inside the failure boundary: SQLite can leave a transaction + * open after a failed COMMIT, and on the shared connection every later BEGIN would then fail. The + * caller gets the original error; a ROLLBACK that fails too goes to `onStranded`. `run` is + * synchronous by contract: an await inside it would let another chat's statements land in this + * transaction. + */ +export function runJournalTransaction( + db: Database.Database, + run: (db: Database.Database) => T, + onStranded: () => void = () => undefined +): T { + db.exec('BEGIN IMMEDIATE') + try { + const result = run(db) + if (result instanceof Promise) { + throw new Error('a chat journal transaction must not await') + } db.exec('COMMIT') + return result } catch (error) { - db.exec('ROLLBACK') + if (db.isTransaction) { + try { + db.exec('ROLLBACK') + } catch (rollbackError) { + console.warn( + '[agent-session-journal] rolling back a failed transaction failed', + rollbackError + ) + onStranded() + } + } throw error } } diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts index 668232e607d9..cefd4bbe368e 100644 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts @@ -10,7 +10,7 @@ import { } from '../../../shared/structured-agent-session-dispatch-rejection' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -30,7 +30,7 @@ const journals = createTrackedJournalOpener() async function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => (clock += 1), mintEpoch: () => `epoch-${clock}`, ...overrides diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts index f428675fb2cb..52a5478c381a 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-controller.ts @@ -2,7 +2,7 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch, type JournalReplacementItem } from './journal-epoch-replacement' import { publishNewEpoch } from './journal-epoch-rollover' import type { JournalLoad } from './journal-open' @@ -16,7 +16,7 @@ export class JournalEpochController { now: () => number mintEpoch: () => string serialize: (run: () => Promise) => Promise - database: () => { db: Database.Database } + database: () => JournalHostDatabase readOnly: () => boolean setReadOnly: (readOnly: boolean) => void highestFence: () => number @@ -27,9 +27,8 @@ export class JournalEpochController { start(reason: AgentJournalEpochReason, fence: number): void { publishNewEpoch({ - db: this.deps.database().db, - sessionId: this.deps.identity.sessionId, - providerHandle: this.deps.identity.providerHandle, + database: this.deps.database(), + identity: this.deps.identity, epoch: this.deps.mintEpoch(), reason, fence, @@ -63,7 +62,7 @@ export class JournalEpochController { assertJournalWritable(this.deps.readOnly(), this.deps.identity.sessionId) assertJournalFence(fence, this.deps.highestFence()) replaceJournalEpoch({ - db: this.deps.database().db, + database: this.deps.database(), identity: this.deps.identity, reason, fence, diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts index 7d87399c62be..646254b4932d 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts @@ -9,12 +9,16 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' +import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch } from './journal-epoch-replacement' import type { JournalLoad } from './journal-open' -import { journalDatabaseFile } from './journal-paths' -import { readJournalEpochRows, readJournalSessionEpoch } from './journal-row-table' -import { createTrackedJournalOpener } from './journal-store-test-open' +import type { AgentSessionJournal } from './journal-store' +import { readJournalSessionEpoch } from './journal-row-table' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -24,9 +28,15 @@ const IDENTITY: AgentSessionJournalIdentity = { providerHandle: { kind: 'codex', threadId: 'thread-1' } } +const PEER: AgentSessionJournalIdentity = { + ...IDENTITY, + sessionId: 'session-peer', + providerHandle: { kind: 'codex', threadId: 'thread-peer' } +} + let root: string let clock = 1_000 -let database: OpenJournalDatabase +let database: JournalHostDatabase const journals = createTrackedJournalOpener() function now(): number { @@ -34,6 +44,15 @@ function now(): number { return clock } +/** Rows stored under the chat and epoch, whatever the chat's pointer names. */ +function storedRows(sessionId: string, epoch: string): number { + return Number( + database.db + .prepare('SELECT count(*) AS total FROM journal_rows WHERE session_id = ? AND epoch = ?') + .get(sessionId, epoch)?.total + ) +} + function item(ordinal: number): AgentJournalItemIdentity { return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } } @@ -43,7 +62,7 @@ function replace(input: { onPublished?: (loaded: JournalLoad) => void }): void { replaceJournalEpoch({ - db: database.db, + database, identity: IDENTITY, reason: 'legacy_import', fence: 1, @@ -57,12 +76,12 @@ function replace(input: { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-replace-')) clock = 1_000 - database = openJournalDatabase(journalDatabaseFile(root)) + database = openTestJournalHostDatabase(root) }) afterEach(async () => { try { - database.db.close() + database.close() } catch { // Already closed by the case. } @@ -82,31 +101,33 @@ describe('journal epoch replacement', () => { expect(published).toHaveLength(1) const epoch = readJournalSessionEpoch(database.db, IDENTITY.sessionId) expect(epoch).toBe(published[0]?.state.epoch) - expect(readJournalEpochRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2) }) - it('discards every superseded row in the same transaction', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem( - item(1), - { kind: 'status', text: 'old' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.appendItem( - item(2), - { kind: 'status', text: 'older' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) + // Keyed by identity: a retired epoch's rows go by (chat, epoch), and no other chat's go with them. + it.each([ + [ + 'a replace', + (journal: AgentSessionJournal) => + journal.replaceEpochItems('legacy_import', 1, [ + { identity: item(9), body: { kind: 'status', text: 'republished' } } + ]) + ], + ['a rollover', (journal: AgentSessionJournal) => journal.rollEpoch('handle_forked', 1)] + ])('discards every superseded row in the same transaction as %s', async (_name, retire) => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const peer = await journals.open({ identity: PEER, stateDirectory: root }) + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal.appendItem(item(1), { kind: 'status', text: 'old' }, scope) + await journal.appendItem(item(2), { kind: 'status', text: 'older' }, scope) + await peer.appendItem(item(1), { kind: 'status', text: 'peer' }, scope) const before = journal.epoch - await journal.replaceEpochItems('legacy_import', 1, [ - { identity: item(9), body: { kind: 'status', text: 'republished' } } - ]) + await retire(journal) expect(journal.epoch).not.toBe(before) - expect(readJournalEpochRows(database.db, IDENTITY.sessionId, before)).toHaveLength(0) - expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([ - { kind: 'status', text: 'republished' } - ]) + expect(storedRows(IDENTITY.sessionId, before)).toBe(0) + expect(storedRows(IDENTITY.sessionId, journal.epoch)).toBe(journal.cursor().sequence) + expect(storedRows(PEER.sessionId, peer.epoch)).toBe(2) }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts index cc9a2adc1bd1..8d94c5b2b5b0 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.ts @@ -1,8 +1,8 @@ // Republishing a live item set into a fresh epoch. // -// One transaction: discard every row, insert the epoch row plus the replacement -// items, move the session projection, and retire any repair marker — this -// republished history is exactly what the marker was holding out for. +// One transaction: discard the old epoch's rows, insert the epoch row plus the +// replacement items, move the session projection, and retire any repair marker +// — this republished history is exactly what the marker was holding out for. import type { AgentJournalItemBody, @@ -11,15 +11,16 @@ import type { AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { - deleteAllJournalRows, + deleteJournalEpochRows, insertJournalRow, - upsertJournalSessionRow + publishJournalSessionEpoch, + readJournalSessionEpoch } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' import { assertJournalFence } from './journal-write-guards' @@ -34,7 +35,7 @@ export type JournalReplacementItem = AgentJournalProducerLinkage & { } export function replaceJournalEpoch(input: { - db: Database.Database + database: JournalHostDatabase identity: AgentSessionJournalIdentity reason: AgentJournalEpochReason fence: number @@ -70,19 +71,18 @@ export function replaceJournalEpoch(input: { rows.push(row) } - input.db.exec('BEGIN IMMEDIATE') - try { - deleteAllJournalRows(input.db) - clearJournalRepairMarker(input.db, input.identity.sessionId) + const { sessionId } = input.identity + input.database.transaction((db) => { + const retired = readJournalSessionEpoch(db, sessionId) + if (retired !== null) { + deleteJournalEpochRows(db, sessionId, retired) + } + clearJournalRepairMarker(db, sessionId) for (const row of rows) { - insertJournalRow(input.db, input.identity.sessionId, row) + insertJournalRow(db, sessionId, row) } - upsertJournalSessionRow(input.db, input.identity.sessionId, epoch, epochRow.ts) - input.db.exec('COMMIT') - } catch (error) { - input.db.exec('ROLLBACK') - throw error - } + publishJournalSessionEpoch(db, input.identity, epoch) + }) // COMMIT landed: on disk the superseded rows are gone and this epoch is the // live one. The caller adopts that immediately, or a later failure leaves the diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts index 5a5f39e5a622..855f60ac3261 100644 --- a/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts +++ b/src/main/native-chat/agent-session-journal/journal-epoch-rollover.ts @@ -6,22 +6,22 @@ // rather than retained — nothing would ever shed them. import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' -import type { AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { - deleteAllJournalRows, + deleteJournalEpochRows, insertJournalRow, - upsertJournalSessionRow + publishJournalSessionEpoch, + readJournalSessionEpoch } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' export function publishNewEpoch(input: { - db: Database.Database - sessionId: string - providerHandle: AgentSessionProviderHandle + database: JournalHostDatabase + identity: AgentSessionJournalIdentity epoch: string reason: AgentJournalEpochReason fence: number @@ -32,7 +32,7 @@ export function publishNewEpoch(input: { const row: JournalRow = { kind: 'epoch', reason: input.reason, - providerHandle: input.providerHandle, + providerHandle: input.identity.providerHandle, // Carries no body: an older host must keep reading a turn-free session past row 1. v: journalRowSchemaVersion([]), epoch: input.epoch, @@ -41,22 +41,21 @@ export function publishNewEpoch(input: { ts: input.now } - input.db.exec('BEGIN IMMEDIATE') - try { - deleteAllJournalRows(input.db) - clearJournalRepairMarker(input.db, input.sessionId) - insertJournalRow(input.db, input.sessionId, row) - upsertJournalSessionRow(input.db, input.sessionId, input.epoch, input.now) - input.db.exec('COMMIT') - } catch (error) { - input.db.exec('ROLLBACK') - throw error - } + const { sessionId } = input.identity + input.database.transaction((db) => { + const retired = readJournalSessionEpoch(db, sessionId) + if (retired !== null) { + deleteJournalEpochRows(db, sessionId, retired) + } + clearJournalRepairMarker(db, sessionId) + insertJournalRow(db, sessionId, row) + publishJournalSessionEpoch(db, input.identity, input.epoch) + }) // COMMIT landed: on disk the superseded prefix is gone and this epoch is the // live one. The caller adopts that immediately, or a later failure leaves the // store writing into an epoch that no longer exists. - const state = createJournalReducerState(input.sessionId, input.epoch) + const state = createJournalReducerState(sessionId, input.epoch) applyJournalRow(state, row) state.oldestSequence = 1 input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) diff --git a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts index 34fed89be537..ed27ed6fa0b2 100644 --- a/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-file-format-remnant.test.ts @@ -5,22 +5,24 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa // `log.jsonl` founds a fresh empty journal beside it and looks exactly like a // chat created seconds ago. One status row is the difference. -import { 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 { afterEach, beforeEach, describe, expect, it } from 'vitest' -import Database from '../../sqlite/sync-database' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { projectStructuredItemsToNativeChat } from '../../../shared/structured-agent-session-projection' -import { openJournalDatabase } from './journal-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { JOURNAL_FILE_FORMAT_REMNANT_DISCLOSURE_IDENTITY } from './journal-file-format-remnant' -import { loadJournal } from './journal-open' -import { journalDatabaseFile } from './journal-paths' +import { journalDirectoryFor } from './journal-paths' import type { AgentSessionJournal } from './journal-store' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal, + deleteTestJournalRow, + insertTestJournalRowJson +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -39,15 +41,21 @@ const journals = createTrackedJournalOpener() function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => (clock += 1), mintEpoch: () => `epoch-${clock}`, ...overrides }) } -function writeRemnant(name = 'log.jsonl'): Promise { - return writeFile(join(root, name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') +/** Where this chat's history lived before the journal was one database per host. */ +function legacyDir(): string { + return journalDirectoryFor(root, IDENTITY) +} + +async function writeRemnant(name = 'log.jsonl'): Promise { + await mkdir(legacyDir(), { recursive: true }) + await writeFile(join(legacyDir(), name), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') } function disclosure(journal: AgentSessionJournal): string | null { @@ -72,7 +80,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const journal = await open() expect(disclosure(journal)).toContain('send a message to pick up where you left off') - expect(disclosure(journal)).toContain(join(root, 'log.jsonl')) + expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) expect(disclosure(journal)).toContain('Codex') }) @@ -85,7 +93,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const journal = await open() - expect(disclosure(journal)).toContain(join(root, 'log.jsonl')) + expect(disclosure(journal)).toContain(join(legacyDir(), 'log.jsonl')) expect(disclosure(journal)).not.toContain('snapshot.json') }) @@ -94,7 +102,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const journal = await open() - expect(disclosure(journal)).toContain(join(root, 'snapshot.json')) + expect(disclosure(journal)).toContain(join(legacyDir(), 'snapshot.json')) }) it('says nothing to a chat that is genuinely new', async () => { @@ -131,7 +139,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const reopened = await open() - expect(disclosure(reopened)).toContain(join(root, 'log.jsonl')) + expect(disclosure(reopened)).toContain(join(legacyDir(), 'log.jsonl')) }) // A repair's epoch is the marker that history was deleted and never rebuilt, @@ -150,11 +158,11 @@ describe('a chat whose history is still in the pre-SQLite format', () => { // the repair publishes an empty `unreconcilable_prefix` epoch and — costing // no malformed row — appends no disclosure of its own. That is the one state // where this branch and a repair meet. - const opened = openJournalDatabase(journalDatabaseFile(root)) + const opened = openTestJournalHostDatabase(root) try { - opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1) } finally { - opened.db.close() + opened.close() } await writeRemnant() @@ -162,20 +170,31 @@ describe('a chat whose history is still in the pre-SQLite format', () => { expect(disclosure(repaired)).toBeNull() // Still asking the provider for the history the repair dropped. - expect(loadJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) + expect(loadTestJournal(root, IDENTITY.sessionId)).toMatchObject({ corrupt: true }) }) - // A latched journal loads empty, so it reaches the same branch — and an append - // into one throws, which would make the session unopenable rather than read-only. - it('writes nothing into a journal latched by a newer schema', async () => { + // A journal latched by a newer row reaches the same branch — and an append into one throws, + // which would make the session unopenable rather than read-only. + it('writes nothing into a journal latched by a newer row', async () => { const founded = await open() + const epoch = founded.epoch await founded.close() - const db = new Database(journalDatabaseFile(root)) - try { - db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - } finally { - db.close() - } + insertTestJournalRowJson( + openTestJournalHostDatabase(root).db, + IDENTITY.sessionId, + 2, + JSON.stringify({ + v: 99, + kind: 'item', + epoch, + seq: 2, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'from a newer build' } + }) + ) await writeRemnant() const latched = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts b/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts deleted file mode 100644 index 7a69de557a46..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Every path that can open a SQLite connection releases it. -// -// Asserting that the happy path closes cleanly proves nothing: these sites are -// reached only when something has already gone wrong. On POSIX a leak is -// SILENT — the unlink succeeds — so each case asserts BOTH that the sidecars -// are gone and that the directory renames and removes, which is the half that -// actually fails on Windows. - -import { access, mkdtemp, rename, 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 { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import Database from '../../sqlite/sync-database' -import { openJournalDatabase } from './journal-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { loadJournal } from './journal-open' -import { journalDatabaseFile } from './journal-paths' -import { createTrackedJournalOpener } from './journal-store-test-open' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } -} - -let base: string -let root: string -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function runningTool(): AgentJournalItemBody { - return { kind: 'tool-call', name: 'command', input: {}, state: 'running' } -} - -async function exists(path: string): Promise { - return access(path) - .then(() => true) - .catch(() => false) -} - -/** The platform-independent proof: an open handle blocks both of these on - * Windows, where every leak in this file actually shows up. */ -async function expectNothingHoldsTheDirectory(): Promise { - const dbPath = journalDatabaseFile(root) - expect(await exists(`${dbPath}-wal`)).toBe(false) - expect(await exists(`${dbPath}-shm`)).toBe(false) - const moved = `${root}-recovered-vtest` - await rename(root, moved) - await rm(moved, { recursive: true }) - root = join(base, `journal-${Math.random().toString(36).slice(2)}`) -} - -beforeEach(async () => { - base = await mkdtemp(join(tmpdir(), 'orca-journal-handles-')) - root = join(base, 'journal') -}) - -afterEach(async () => { - await journals.closeAll() - await rm(base, { recursive: true, force: true }) -}) - -describe('the standalone probe owns its own connection', () => { - it('leaves no handle behind after fifty repeated loads', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem( - item(1), - { kind: 'message', role: 'user', blocks: [] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - - for (let attempt = 0; attempt < 50; attempt += 1) { - const loaded = await loadJournal(root, IDENTITY.sessionId) - expect(loaded?.readOnly).toBe(false) - } - await expectNothingHoldsTheDirectory() - }) - - it('leaves no handle behind after fifty loads of a latched future schema', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.close() - const seeded = openJournalDatabase(journalDatabaseFile(root)) - seeded.db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 3}`) - seeded.db.close() - - for (let attempt = 0; attempt < 50; attempt += 1) { - // The latched open closes the probe connection and returns the read-only - // reopen, so probing repeatedly leaves nothing on a file we must not touch. - expect((await loadJournal(root, IDENTITY.sessionId))?.readOnly).toBe(true) - } - // A read-only connection cannot remove the sidecars it materialized, so only - // the rename/remove half is expected to hold here. - const moved = `${root}-recovered-vtest` - await rename(root, moved) - await rm(moved, { recursive: true }) - root = join(base, 'journal-after-latched') - }) - - it('returns null for a session with no journal, without creating one', async () => { - expect(await loadJournal(root, IDENTITY.sessionId)).toBeNull() - expect(await exists(journalDatabaseFile(root))).toBe(false) - }) -}) - -describe('failure paths inside the open call', () => { - // Site 1: the raw connection is owned by `openJournalDatabase` until it returns. - it('closes the raw connection when the version read cannot run', async () => { - await journals.open({ identity: IDENTITY, journalDir: root }).then((journal) => journal.close()) - await writeFile(journalDatabaseFile(root), 'this is not a database', 'utf8') - - expect(() => openJournalDatabase(journalDatabaseFile(root))).toThrow() - await expectNothingHoldsTheDirectory() - }) - - it('closes the raw connection when the migration cannot start', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.close() - const blocker = openJournalDatabase(journalDatabaseFile(root)) - // Roll the stored version back so the migration runs, then hold the write - // lock it needs: the throw lands after the connection already exists. - blocker.db.pragma('user_version = 0') - blocker.db.exec('BEGIN IMMEDIATE') - blocker.db.exec("INSERT INTO journal_sessions VALUES ('other', 'e', 1)") - try { - expect(() => openJournalDatabase(journalDatabaseFile(root))).toThrow() - } finally { - blocker.db.exec('ROLLBACK') - blocker.db.close() - } - await expectNothingHoldsTheDirectory() - }, 60_000) - - // Sites 2 and 3: `open()` closes its own connection on any throw after the - // connection exists, which is what lets the factory need no `finally`. - it('leaves nothing open when a post-connection step of open() throws', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) - await journal.appendItem(item(1), runningTool(), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - - // Replay runs after the connection is open, so a read it cannot serve - // throws with the handle already held. - const exec = vi.spyOn(Database.prototype, 'prepare').mockImplementation(() => { - throw new Error('replay cannot read this journal') - }) - try { - await expect(journals.open({ identity: IDENTITY, journalDir: root })).rejects.toThrow( - 'replay cannot read this journal' - ) - } finally { - exec.mockRestore() - } - await expectNothingHoldsTheDirectory() - }) -}) 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 new file mode 100644 index 000000000000..9ce0e0c34319 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -0,0 +1,155 @@ +// The host journal database for a test's state directory. One per directory per test process, as +// one per state directory per host process in production, so a test that "restarts" a host on the +// same directory reads the same database the way a restarted host would. + +import { resolve } from 'node:path' +import { JournalHostDatabase } from './journal-host-database' +import { replayJournal, type JournalLoad } from './journal-open' +import { serializeJournalRow, type JournalRow } from './journal-row-schema' +import { + iterateJournalEpochRows, + publishJournalSessionEpoch, + readJournalSessionEpoch, + type JournalStoredRow +} from './journal-row-table' +import type Database from '../../sqlite/sync-database' +import type { AgentSessionJournal } from './journal-store' +import type { AgentSessionJournalOptions } from './journal-store-contracts' +import { openAgentSessionJournal } from './journal-store-factory' + +const opened = new Map() + +export function openTestJournalHostDatabase(stateDirectory: string): JournalHostDatabase { + const directory = resolve(stateDirectory) + const existing = opened.get(directory) + if (existing && !existing.isClosed) { + return existing + } + const database = JournalHostDatabase.open(directory) + opened.set(directory, database) + return database +} + +/** Closes every database this process opened for tests. */ +export function closeTestJournalHostDatabases(): void { + for (const database of opened.values()) { + database.close() + } + opened.clear() +} + +export type TestJournalOptions = Omit & { + /** The state directory whose one journal database holds this chat. */ + stateDirectory: string +} + +export type TrackedJournalOpener = { + open: (options: TestJournalOptions) => Promise + track: (journal: T) => T + /** Drains every tracked journal, then closes the databases. */ + closeAll: () => Promise +} + +export function createTrackedJournalOpener(): TrackedJournalOpener { + const journals: AgentSessionJournal[] = [] + return { + open: async ({ stateDirectory, ...options }) => { + const journal = await openAgentSessionJournal({ + ...options, + database: openTestJournalHostDatabase(stateDirectory) + }) + journals.push(journal) + return journal + }, + track: (journal) => { + journals.push(journal) + return journal + }, + closeAll: async () => { + await Promise.allSettled(journals.splice(0).map((journal) => journal.close())) + closeTestJournalHostDatabases() + } + } +} + +/** What a fresh open of the chat would replay, read from the test state directory's database. */ +export function loadTestJournal(stateDirectory: string, sessionId: string): JournalLoad | null { + return replayJournal(openTestJournalHostDatabase(stateDirectory).db, sessionId) +} + +// Staging on-disk states for a case, addressed the way the case thinks of them: by chat and sequence. + +function liveEpoch(db: Database.Database, sessionId: string): string { + const epoch = readJournalSessionEpoch(db, sessionId) + if (epoch === null) { + throw new Error(`no journal for ${sessionId}`) + } + return epoch +} + +/** Points the chat at a new epoch, as a publish would. */ +export function publishTestJournalEpoch( + db: Database.Database, + sessionId: string, + epoch: string +): void { + publishJournalSessionEpoch(db, { sessionId, workspaceId: 'ws-1' }, epoch) +} + +/** The chat's rows of `epoch` — none once that epoch is no longer the live one. */ +export function readTestJournalRows( + db: Database.Database, + sessionId: string, + epoch: string +): JournalStoredRow[] { + return readJournalSessionEpoch(db, sessionId) === epoch + ? [...iterateJournalEpochRows(db, sessionId, epoch)] + : [] +} + +/** The chat's rows of whichever epoch is live now. */ +export function liveTestJournalRows(db: Database.Database, sessionId: string): JournalStoredRow[] { + const epoch = readJournalSessionEpoch(db, sessionId) + return epoch === null ? [] : [...iterateJournalEpochRows(db, sessionId, epoch)] +} + +/** The row under the chat's live epoch, whatever epoch its body names. */ +export function insertTestJournalRow( + db: Database.Database, + sessionId: string, + row: JournalRow +): void { + insertTestJournalRowJson(db, sessionId, row.seq, serializeJournalRow(row), row.ts) +} + +/** A raw `row_json` at `seq`, the way a newer build or a bad write would leave it. */ +export function insertTestJournalRowJson( + db: Database.Database, + sessionId: string, + seq: number, + rowJson: string, + ts = 1 +): void { + db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ).run(sessionId, liveEpoch(db, sessionId), seq, ts, rowJson) +} + +export function updateTestJournalRowJson( + db: Database.Database, + sessionId: string, + seq: number, + rowJson: string +): void { + db.prepare( + 'UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND epoch = ? AND seq = ?' + ).run(rowJson, sessionId, liveEpoch(db, sessionId), seq) +} + +export function deleteTestJournalRow(db: Database.Database, sessionId: string, seq: number): void { + db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq = ?').run( + sessionId, + liveEpoch(db, sessionId), + seq + ) +} diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts new file mode 100644 index 000000000000..84ff9775022b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -0,0 +1,260 @@ +// The one connection every chat's writes share: its transactions, its busy handling, and the order +// in which it closes at quit. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +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 type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { spawnProcess } from '../../../shared/child-process/run-process' +import { tearDownRuntime } from '../../runtime/structured-agent-session-runtime-teardown' +import { journalDatabasePath } from './journal-host-database' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' +import type Database from '../../sqlite/sync-database' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const OTHER: AgentSessionJournalIdentity = { + ...IDENTITY, + sessionId: 'session-2', + providerHandle: { kind: 'codex', threadId: 'thread-2' } +} + +const SUBMISSION = { + clientMessageId: 'msg-1', + payloadFingerprint: 'e'.repeat(64), + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'hi' }] + }, + fence: 1 +} + +let root: string +const journals = createTrackedJournalOpener() + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +/** The next row insert breaks a deferred foreign key, so its transaction's COMMIT fails and SQLite + * leaves the transaction open, as it may for a COMMIT that fails on its own. */ +function failCommits(db: Database.Database): () => void { + db.exec(`CREATE TEMP TABLE commit_probe_parent (id INTEGER PRIMARY KEY); +CREATE TEMP TABLE commit_probe_child ( + parent INTEGER REFERENCES commit_probe_parent (id) DEFERRABLE INITIALLY DEFERRED +); +CREATE TEMP TRIGGER commit_probe AFTER INSERT ON main.journal_rows +BEGIN INSERT INTO commit_probe_child (parent) VALUES (1); END`) + return () => db.exec('DROP TRIGGER temp.commit_probe') +} + +function text(value: string) { + return { + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: value }] + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-host-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('the shared connection', () => { + // T8: another connection holding the write lock makes an append wait, not fail. + it('waits out another writer instead of failing the append', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const holder = spawnProcess({ + program: process.execPath, + args: [ + '-e', + `const { DatabaseSync } = require('node:sqlite') +const db = new DatabaseSync(process.argv[1]) +db.exec('BEGIN IMMEDIATE') +process.stdout.write('holding') +setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, + journalDatabasePath(root) + ], + timeoutMs: 30_000 + }) + const exited = new Promise((resolve) => holder.once('exit', resolve)) + await new Promise((resolve) => holder.stdout.once('data', () => resolve())) + + await expect( + journal.appendItem(item(1), text('after the wait'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).resolves.toBeDefined() + expect(await exited).toBe(0) + }) + + // The #22993 companion: a transaction that cannot begin fails that write alone. + it('fails only the write whose transaction could not begin', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const exec = database.db.exec.bind(database.db) + const busy = Object.assign(new Error('database is locked'), { + code: 'ERR_SQLITE_ERROR', + errcode: 5 + }) + vi.spyOn(database.db, 'exec').mockImplementationOnce(() => { + throw busy + }) + + await expect( + journal.appendItem(item(1), text('refused'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toBe(busy) + vi.mocked(database.db.exec).mockImplementation(exec) + await expect( + journal.appendItem(item(2), text('next'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).resolves.toBeDefined() + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([text('next')]) + }) + + it('frees the connection after a failed COMMIT: nothing adopted, published or acknowledged', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const other = await journals.open({ identity: OTHER, stateDirectory: root }) + const connection = openTestJournalHostDatabase(root).db + const published = vi.fn() + journal.observeCommits(published) + const cursor = journal.cursor() + const rows = () => + readTestJournalRows(connection, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) + const rowsBefore = rows() + const stopFailing = failCommits(connection) + + await expect(journal.appendSubmission(SUBMISSION)).rejects.toThrow( + 'FOREIGN KEY constraint failed' + ) + expect(connection.isTransaction).toBe(false) + stopFailing() + + expect(published).not.toHaveBeenCalled() + expect(journal.cursor()).toEqual(cursor) + expect(journal.submissions()).toEqual([]) + expect(rows()).toEqual(rowsBefore) + await expect( + other.appendItem(item(1), text('another chat'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).resolves.toBeDefined() + }) + + it('refuses every chat while a failed transaction will not roll back, then serves them', async () => { + const other = await journals.open({ identity: OTHER, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const connection = database.db + const exec = connection.exec.bind(connection) + let rollbackFails = true + vi.spyOn(connection, 'exec').mockImplementation((sql) => { + if (sql === 'ROLLBACK' && rollbackFails) { + throw Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) + } + exec(sql) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const stopFailing = failCommits(connection) + + // A first-use copy's batch, under the unsynced level. Its caller gets the COMMIT's own error. + expect(() => + database.unsyncedTransaction((db) => + db + .prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + .run('copying', 'epoch-copying', 1, 1, '{}') + ) + ).toThrow('FOREIGN KEY constraint failed') + expect(connection.isTransaction).toBe(true) + await expect( + other.appendItem(item(1), text('refused'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + }) + + rollbackFails = false + expect(database.db.isTransaction).toBe(false) + stopFailing() + // Restored with the ROLLBACK: no later commit runs at the copy's unsynced level. + expect(Number(connection.pragma('synchronous', { simple: true }))).toBe(2) + await expect( + other.appendItem(item(1), text('served'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).resolves.toBeDefined() + expect(readTestJournalRows(connection, OTHER.sessionId, other.epoch)).not.toHaveLength(0) + }) + + it('refuses a transaction body that awaits', () => { + const database = openTestJournalHostDatabase(root) + expect(() => database.transaction(async () => undefined)).toThrow('must not await') + // Rolled back: the connection is free for the next transaction. + expect(database.db.isTransaction).toBe(false) + }) +}) + +describe('quit', () => { + // T-quit-drain: a sink write still in flight while the host flushes lands before the one + // connection closes, because the connection closes last. + it('closes the one connection only after the host has flushed what was in flight', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const installed = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `flushAllStreamedEvents` on the host. + host: { + flushAllStreamedEvents: async () => { + // A child's last row, delivered while quit is draining its sink. + await new Promise((resolve) => setTimeout(resolve, 10)) + await journal.appendItem(item(1), text('written during quit'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + } as never, + adapter: { closeAll: async () => undefined }, + journalDatabase: database, + waitForRecovery: async () => undefined + } + + await tearDownRuntime(installed, 'quit') + + expect(database.isClosed).toBe(true) + closeTestJournalHostDatabases() + const reopened = openTestJournalHostDatabase(root) + expect( + readTestJournalRows(reopened.db, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) + ).toEqual([1, 2]) + }) +}) 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 new file mode 100644 index 000000000000..b6999e4ef559 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -0,0 +1,120 @@ +// The host's one chat journal database: every structured chat on this state directory, in one +// file, on one connection. The app and orcad instance locks keep a second process off a profile. +// +// A chat's store owns no connection. It goes through this object, so there is nothing per chat to +// open, close, retry or leak, and the connection closes exactly once, last, at host teardown. + +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import { + JOURNAL_SYNCHRONOUS, + openJournalDatabase, + runJournalTransaction, + type OpenJournalDatabase +} from './journal-database' +import { journalOpenRefusalError } from './journal-open-failure' +import { journalDirectoryFor } from './journal-paths' +import { AgentSessionJournalError } from './journal-write-guards' + +const JOURNAL_DATABASE_FILE = 'agent-session-journal.db' + +export function journalDatabasePath(stateDirectory: string): string { + return join(stateDirectory, JOURNAL_DATABASE_FILE) +} + +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 + /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ + private stranded = false + + private constructor( + readonly stateDirectory: string, + opened: OpenJournalDatabase + ) { + this.connection = opened.db + this.readOnly = opened.readOnly + } + + static open(stateDirectory: string): JournalHostDatabase { + mkdirSync(stateDirectory, { recursive: true }) + return new JournalHostDatabase( + stateDirectory, + openJournalDatabase(journalDatabasePath(stateDirectory)) + ) + } + + get isClosed(): boolean { + return this.connection === null + } + + get db(): Database.Database { + const connection = this.connection + if (!connection) { + throw new AgentSessionJournalError('journal_closed', 'the chat journal database is closed') + } + if (this.stranded) { + this.rollBackStrandedTransaction(connection) + } + return connection + } + + /** One IMMEDIATE transaction; see `runJournalTransaction`. */ + transaction(run: (db: Database.Database) => T): T { + return runJournalTransaction(this.db, run, () => { + this.stranded = true + }) + } + + /** + * The same transaction, committed without an fsync: for rows no reader follows until a later + * synced commit, which under WAL makes every earlier frame durable too. The setting is restored + * in the same task, so no other chat's commit runs under it. + */ + unsyncedTransaction(run: (db: Database.Database) => T): T { + const db = this.db + db.pragma('synchronous = NORMAL') + try { + return this.transaction(run) + } finally { + // SQLite refuses the change inside a transaction; freeing a stranded one restores it. + if (!db.isTransaction) { + db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) + } + } + } + + /** Where this chat's history lived before the journal was one database per host. */ + legacyDirectoryFor( + identity: Pick + ): string { + return journalDirectoryFor(this.stateDirectory, identity) + } + + /** Last, after every store has drained. A close that fails keeps the handle, so the retried + * teardown closes this same connection. */ + close(): void { + this.connection?.close() + this.connection = null + } + + /** + * A failed transaction whose ROLLBACK failed too is still open: every later BEGIN would fail + * inside it and every read would see rows that never committed. Each use retries the ROLLBACK, + * and until one goes through every chat is refused the way a journal that will not open is. + */ + private rollBackStrandedTransaction(connection: Database.Database): void { + if (connection.isTransaction) { + try { + connection.exec('ROLLBACK') + } catch (error) { + throw journalOpenRefusalError(error) + } + } + connection.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) + this.stranded = false + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 7751265b72e9..a38a37fd0c82 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -19,6 +19,7 @@ import { importLegacyTranscriptIntoJournal } from './journal-legacy-import' import { DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { openAgentSessionJournal } from './journal-store-factory' import type { AgentSessionJournal } from './journal-store' +import { openTestJournalHostDatabase } from './journal-host-database-test-support' // No shipped decoder emits a subagent roster, so the roster bounds are reached by standing one in. const decodedClaudeOverride = vi.hoisted((): { message: NativeChatMessage | null } => ({ @@ -73,7 +74,7 @@ async function open( ): Promise { return openAgentSessionJournal({ identity: identity(agent, sessionId), - journalDir: root, + database: openTestJournalHostDatabase(root), now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides @@ -478,7 +479,9 @@ describe('payload bounds on import', () => { describe('import failures', () => { it('rejects a legacy source above the fixed 16 MiB import cap before decoding', async () => { const journalDir = join(root, 'oversized-source-journal') - const journal = await open('claude', CLAUDE_SESSION, { journalDir }) + const journal = await open('claude', CLAUDE_SESSION, { + database: openTestJournalHostDatabase(journalDir) + }) const filePath = join(root, 'oversized-source.jsonl') await writeFile(filePath, 'x'.repeat(16 * 1024 * 1024 + 1), 'utf8') const epoch = journal.epoch @@ -502,7 +505,9 @@ describe('import failures', () => { it('bounds oversized legacy tool-call input before journal publication', async () => { const journalDir = join(root, 'bounded-tool-input-journal') const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 64 } - const journal = await open('claude', CLAUDE_SESSION, { journalDir }) + const journal = await open('claude', CLAUDE_SESSION, { + database: openTestJournalHostDatabase(journalDir) + }) const filePath = await writeFixture('oversized-tool-input.jsonl', [ { parentUuid: null, @@ -630,7 +635,7 @@ describe('multi-block legacy messages', () => { it('bounds a Claude tool call that shares its message with narration', async () => { const journal = await open('claude', CLAUDE_SESSION, { - journalDir: join(root, 'claude-mixed-journal') + database: openTestJournalHostDatabase(join(root, 'claude-mixed-journal')) }) const filePath = await writeFixture('claude-mixed.jsonl', [ { @@ -676,7 +681,7 @@ describe('multi-block legacy messages', () => { it('bounds a Grok tool call that shares its row with assistant text', async () => { const journal = await open('grok', CODEX_SESSION, { - journalDir: join(root, 'grok-mixed-journal') + database: openTestJournalHostDatabase(join(root, 'grok-mixed-journal')) }) const filePath = await writeFixture('grok-mixed.jsonl', [ { @@ -704,7 +709,7 @@ describe('multi-block legacy messages', () => { it('bounds an omp execution cell, whose invocation always ships with its output', async () => { const journal = await open('omp', CODEX_SESSION, { - journalDir: join(root, 'omp-mixed-journal') + database: openTestJournalHostDatabase(join(root, 'omp-mixed-journal')) }) const filePath = await writeFixture('omp-mixed.jsonl', [ { 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 a5427f76fdc7..2e9e21d024f4 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 @@ -7,10 +7,13 @@ import { openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, - journalOpenReadRefusal + journalOpenReadRefusal, + journalOpenRefusal, + journalOpenRefusalError } from './journal-open-failure' -import { loadJournal } from './journal-open' -import { journalDatabaseFile } from './journal-paths' +import { AgentSessionJournalError } from './journal-write-guards' +import { journalDatabasePath } from './journal-host-database' +import { replayJournal } from './journal-open' let root: string @@ -22,10 +25,15 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -/** What the journal's own open throws for the file as it stands. */ +/** What the journal's own open, then a chat's replay, throws for the file as it stands. */ function openFailure(): unknown { try { - loadJournal(root, 'session-1') + const db = openJournalDatabase(journalDatabasePath(root)).db + try { + replayJournal(db, 'session-1') + } finally { + db.close() + } } catch (error) { return error } @@ -43,17 +51,17 @@ function systemError(code: string, errno: number): Error { describe('classifyJournalOpenFailure', () => { it('calls a journal that is not a database corrupt', async () => { - await writeFile(journalDatabaseFile(root), 'not a database '.repeat(64)) + await writeFile(journalDatabasePath(root), 'not a database '.repeat(64)) const error = openFailure() expect(error).toMatchObject({ errcode: 26 }) expect(classifyJournalOpenFailure(error)).toBe('journalCorrupt') }) it('calls a journal whose pages are damaged corrupt', async () => { - const path = journalDatabaseFile(root) - const opened = openJournalDatabase(path) - opened.db.exec('PRAGMA journal_mode = DELETE') - opened.db.close() + const path = journalDatabasePath(root) + const opened = openJournalDatabase(path).db + opened.exec('PRAGMA journal_mode = DELETE') + opened.close() const bytes = await readFile(path) // Page 1 holds the header and schema; every table's root page follows it. bytes.fill(0xab, 4096) @@ -157,3 +165,33 @@ describe('createJournalOpenReadRefusals', () => { vi.restoreAllMocks() }) }) + +// Only an update gets past a journal a newer Orca wrote, so it has a reason of its own: a client +// that chose words by `journalUnavailable` said to try again, and retrying never cleared it. +describe('a journal a newer Orca wrote', () => { + const readOnly = () => + new AgentSessionJournalError('journal_read_only', 'the journal uses a newer schema') + + it('refuses a write with its own reason, and the words released clients print', () => { + // As the wire carries it (the mobile and older-client tests read this shape). + expect(JSON.parse(JSON.stringify(journalOpenRefusal(readOnly())))).toEqual({ + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + }) + expect(journalOpenRefusalError(readOnly()).refusal).toMatchObject({ + details: { reason: 'journalWrittenByNewerOrca' } + }) + }) + + it('refuses a read with the same reason', () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + expect(journalOpenReadRefusal(readOnly()).refusal).toMatchObject({ + details: { reason: 'journalWrittenByNewerOrca' } + }) + expect(createJournalOpenReadRefusals().refusal('session-1', readOnly()).refusal).toMatchObject({ + details: { reason: 'journalWrittenByNewerOrca' } + }) + vi.restoreAllMocks() + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 5e4cff1f3ee2..5e014c279cb1 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -1,24 +1,47 @@ // What a failed journal open means for the chat: its history is damaged, which no retry reads -// past, or the open failed in a way that can clear (a lock, permissions, too many open files). +// past; a newer Orca wrote it, which only an update gets past; or the open failed in a way that can +// clear (a lock, permissions, too many open files). import type { AgentSessionRefusalReason } from '../../../shared/agent-session-refusal-details' +import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' import { AgentSessionRefusalError, + agentSessionRefusalError, isAgentSessionRefusalError, - refuse + refuse, + type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' +import { AgentSessionJournalError } from './journal-write-guards' -export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> +type JournalRefusalReason = AgentSessionRefusalReason<'agent_session_journal_unreadable'> + +/** Why an open failed, as the storage shows it; a newer Orca's journal is told apart first. */ +export type JournalOpenFailure = Exclude + +/** A per-chat file whose copy did not read back as the file: the history is not usable here. */ +export class JournalImportMismatchError extends Error { + override readonly name = 'JournalImportMismatchError' +} + +/** A database only an unreleased development build wrote: left as found, never migrated. */ +export class JournalUnreleasedSchemaError extends Error { + override readonly name = 'JournalUnreleasedSchemaError' +} // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 -/** Damage only where the storage says so; anything unproven can clear. */ +/** Unusable only where proven: damage the storage reports, a copy that did not verify, or a file + * only an unreleased build wrote. Anything unproven can clear. */ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { let current = error for (let depth = 0; depth < MAX_CAUSE_DEPTH && current !== undefined; depth += 1) { - if (isSqliteCorruption(current)) { + if ( + isSqliteCorruption(current) || + current instanceof JournalImportMismatchError || + current instanceof JournalUnreleasedSchemaError + ) { return 'journalCorrupt' } current = current instanceof Error ? current.cause : undefined @@ -26,6 +49,59 @@ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { return 'journalUnavailable' } +// Released clients print a refusal's message for a send; it fits a Stop too. +const JOURNAL_OPEN_MESSAGE: Record = { + journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), + journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']), + journalWrittenByNewerOrca: agentSessionWriteNoticeEnglish([ + 'savedByNewerOrca', + 'updateOrcaToKeepUsing' + ]) +} + +export const JOURNAL_NEWER_SCHEMA_MESSAGE = JOURNAL_OPEN_MESSAGE.journalWrittenByNewerOrca + +/** A newer Orca wrote the database, or one of this chat's rows: an update is what gets past it. */ +export function isJournalWrittenByNewerOrca(error: unknown): boolean { + return error instanceof AgentSessionJournalError && error.code === 'journal_read_only' +} + +/** Why a journal open failed. A newer Orca's journal clears when this Orca is updated, so it is + * not damage. */ +function journalRefusalReason(error: unknown): JournalRefusalReason { + return isJournalWrittenByNewerOrca(error) + ? 'journalWrittenByNewerOrca' + : classifyJournalOpenFailure(error) +} + +/** Why a journal open failed, and the words a released client prints for it. */ +function journalOpenFailureWords(error: unknown): { + reason: JournalRefusalReason + message: string +} { + const reason = journalRefusalReason(error) + return { reason, message: JOURNAL_OPEN_MESSAGE[reason] } +} + +/** The refusal a failed journal open answers with, as the wire carries it. One already classified + * passes through as it is: its own cause is not on it to classify again. */ +export function journalOpenRefusal(error: unknown): AgentSessionWireRefusal { + if (isAgentSessionRefusalError(error)) { + return error.refusal + } + const { reason, message } = journalOpenFailureWords(error) + return refuse('agent_session_journal_unreadable', { reason }, message) +} + +/** The same refusal, thrown: for a host that could not open the journal at all. */ +export function journalOpenRefusalError(error: unknown): AgentSessionRefusalError { + if (isAgentSessionRefusalError(error)) { + return error + } + const { reason, message } = journalOpenFailureWords(error) + return agentSessionRefusalError('agent_session_journal_unreadable', { reason }, message) +} + /** * What a read throws when the conversation it reaches cannot be opened. The storage's own text * (a path, "file is not a database") goes to the log only; the reader gets the classified refusal, @@ -35,7 +111,7 @@ export function journalOpenReadRefusal(error: unknown): AgentSessionRefusalError if (isAgentSessionRefusalError(error)) { return error } - return unreadableRefusal(error, classifyJournalOpenFailure(error), true) + return unreadableRefusal(error, journalRefusalReason(error), true) } const MAX_LOGGED_SESSIONS = 256 @@ -51,7 +127,7 @@ export function createJournalOpenReadRefusals() { if (isAgentSessionRefusalError(error)) { return error } - const reason = classifyJournalOpenFailure(error) + const reason = journalRefusalReason(error) const failure = `${reason}:${error instanceof Error ? error.message : String(error)}` const repeat = logged.get(sessionId) === failure // Past the cap a new session logs every failure rather than evict another's. @@ -69,7 +145,7 @@ export function createJournalOpenReadRefusals() { function unreadableRefusal( error: unknown, - reason: JournalOpenFailure, + reason: JournalRefusalReason, log: boolean ): AgentSessionRefusalError { if (log) { diff --git a/src/main/native-chat/agent-session-journal/journal-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index d86da773f71e..ae753839c40d 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -6,10 +6,7 @@ // in the surviving sequence is corruption, and the caller rolls the epoch // rather than rendering a partial timeline. -import { existsSync } from 'node:fs' import type Database from '../../sqlite/sync-database' -import { openJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' import { applyJournalRow, createJournalReducerState, @@ -29,7 +26,7 @@ const FIRST_JOURNAL_SEQUENCE = 1 export type JournalLoad = { state: JournalReducerState - /** A future schema version was met: no writes, no deletion. */ + /** A row from a future schema was met: no writes, no deletion. */ readOnly: boolean /** Set when the surviving prefix is unusable and the caller must roll the epoch. */ corrupt: boolean @@ -41,24 +38,48 @@ export type JournalLoad = { truncateFrom?: number } -/** - * Replay on a connection this function does NOT own. Returns null when the - * session has no journal yet. - */ -export function replayJournal( - db: Database.Database, - readOnly: boolean, - sessionId: string -): JournalLoad | null { - if (readOnly) { - return emptyReadOnlyLoad(sessionId) - } +/** Replays one chat from the host's database. Returns null when the chat has no journal yet. */ +export function replayJournal(db: Database.Database, sessionId: string): JournalLoad | null { const epoch = readJournalSessionEpoch(db, sessionId) - if (!epoch) { + if (epoch === null) { return null } - const state = createJournalReducerState(sessionId, epoch) - const repairedFrom = pendingJournalRepairSequence(db, sessionId, epoch) + return foldJournalRows({ + sessionId, + epoch, + repairedFrom: pendingJournalRepairSequence(db, sessionId, epoch), + rows: iterateJournalEpochRows(db, sessionId, epoch) + }) +} + +/** Folds one epoch's stored rows, in sequence order, wherever they are stored. */ +function foldJournalRows( + input: JournalRowFoldInput & { rows: Iterable<{ seq: number; rowJson: string }> } +): JournalLoad { + const fold = startJournalRowFold(input) + for (const entry of input.rows) { + if (!fold.add(entry)) { + break + } + } + return fold.finish() +} + +type JournalRowFoldInput = { + sessionId: string + epoch: string + /** The sequence a pending repair on this epoch left free. */ + repairedFrom: number | null +} + +/** The same fold, fed a row at a time, for a caller that yields between batches of rows. */ +export function startJournalRowFold(input: JournalRowFoldInput): { + /** False once the fold has stopped: the rest of the rows are not read. */ + add: (entry: { seq: number; rowJson: string }) => boolean + finish: () => JournalLoad +} { + const { repairedFrom } = input + const state = createJournalReducerState(input.sessionId, input.epoch) let expectedSequence = FIRST_JOURNAL_SEQUENCE let gapSequence: number | undefined let unanchoredSequence: number | undefined @@ -69,22 +90,22 @@ export function replayJournal( let latched = false let truncateFrom: number | undefined - for (const entry of iterateJournalEpochRows(db, sessionId, epoch)) { + const add = (entry: { seq: number; rowJson: string }): boolean => { const parsed = parseJournalRow(entry.rowJson) if (!parsed.ok) { truncateFrom = entry.seq latched = parsed.unreadable malformedRows = parsed.unreadable ? 0 : 1 - break + return false } const row = parsed.row // Parse past a gap so an unreadable future row still latches read-only. if (gapSequence !== undefined) { - continue + return true } if (row.seq !== expectedSequence) { gapSequence = row.seq - continue + return true } expectedSequence += 1 if (row.seq === FIRST_JOURNAL_SEQUENCE) { @@ -95,7 +116,7 @@ export function replayJournal( } } if (!anchor) { - continue + return true } applyJournalRow(state, row) const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID @@ -103,22 +124,26 @@ export function replayJournal( repairHasContent ||= repairedFrom !== null && row.seq >= repairedFrom providerHasContent ||= row.seq >= FIRST_JOURNAL_SEQUENCE + 1 } + return true } - // Anchor rejection takes precedence over a gap, which takes precedence over malformed rows. - truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom - state.oldestSequence = FIRST_JOURNAL_SEQUENCE - return { - state, - readOnly: latched, - corrupt: - gapSequence !== undefined || - malformedRows > 0 || - (!latched && !anchor) || - (repairedFrom !== null && !repairHasContent) || - (anchor?.reason === 'unreconcilable_prefix' && !providerHasContent), - malformedRows, - ...(truncateFrom !== undefined && !latched ? { truncateFrom } : {}) + const finish = (): JournalLoad => { + // Anchor rejection takes precedence over a gap, which takes precedence over malformed rows. + truncateFrom = unanchoredSequence ?? gapSequence ?? truncateFrom + state.oldestSequence = FIRST_JOURNAL_SEQUENCE + return { + state, + readOnly: latched, + corrupt: + gapSequence !== undefined || + malformedRows > 0 || + (!latched && !anchor) || + (repairedFrom !== null && !repairHasContent) || + (anchor?.reason === 'unreconcilable_prefix' && !providerHasContent), + malformedRows, + ...(truncateFrom !== undefined && !latched ? { truncateFrom } : {}) + } } + return { add, finish } } /** Rows after a cursor, in sequence order. Stops at the first row this build @@ -140,27 +165,3 @@ export function readJournalRowsAfterCursor( } return rows } - -/** Standalone probe. Opens its own connection and closes it before returning, - * so a caller holding only the returned value holds no handle. */ -export function loadJournal(journalDir: string, sessionId: string): JournalLoad | null { - const dbPath = journalDatabaseFile(journalDir) - if (!existsSync(dbPath)) { - return null - } - const opened = openJournalDatabase(dbPath) - try { - return replayJournal(opened.db, opened.readOnly, sessionId) - } finally { - opened.db.close() - } -} - -function emptyReadOnlyLoad(sessionId: string): JournalLoad { - return { - state: createJournalReducerState(sessionId, ''), - readOnly: true, - corrupt: false, - malformedRows: 0 - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-paths.ts b/src/main/native-chat/agent-session-journal/journal-paths.ts index c21a7675f009..cb7d44e8c83f 100644 --- a/src/main/native-chat/agent-session-journal/journal-paths.ts +++ b/src/main/native-chat/agent-session-journal/journal-paths.ts @@ -1,24 +1,17 @@ -// Where a session journal lives. +// Where a chat's history lived when the journal was one database per chat. // -// Host-side per-workspace state, keyed by workspace id — never inside the -// user's working tree. A journal in the tree would show up in `git status`, -// vanish with `git worktree remove`, and have no defined home in a folder -// workspace that is not a repository at all. Keying by id rather than by path -// makes a worktree, a folder workspace, a WSL distro, and an SSH host identical. -// -// The host environment port supplies the root so desktop Electron and the -// headless/SSH runtime resolve their own durable state directories. +// Nothing writes here any more: the host's one database replaced it. The importer copies a file it +// finds here on that chat's first use and deletes it once the copy verifies; the pre-SQLite format +// remnant check looks here too. +// Host-side per-workspace state, keyed by hashed ids — never inside the user's working tree. import { createHash } from 'node:crypto' import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { getAppEnvironment } from '../../../shared/app-environment' const JOURNAL_DIR_NAME = 'agent-session-journal' -/** Filesystem-safe, collision-resistant segment for an arbitrary id. Ids come - * from providers and workspaces and can contain path separators or characters - * Windows rejects, so they are hashed rather than sanitized. */ +/** Filesystem-safe, collision-resistant segment for an arbitrary id. */ export function journalPathSegment(value: string): string { return createHash('sha256').update(value, 'utf8').digest('hex').slice(0, 32) } @@ -36,14 +29,9 @@ export function journalDirectoryFor( ) } -/** Default host state root. */ -export function defaultJournalRoot(): Promise { - return Promise.resolve(getAppEnvironment().getPath('userData')) -} - -export const JOURNAL_DATABASE_FILE = 'journal.db' +const LEGACY_JOURNAL_DATABASE_FILE = 'journal.db' -/** The session's SQLite database, inside the directory `journalDirectoryFor` names. */ -export function journalDatabaseFile(journalDir: string): string { - return join(journalDir, JOURNAL_DATABASE_FILE) +/** The per-chat SQLite file inside the directory `journalDirectoryFor` names. */ +export function legacyJournalDatabaseFile(journalDir: string): string { + return join(journalDir, LEGACY_JOURNAL_DATABASE_FILE) } diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts new file mode 100644 index 000000000000..ea28ba4ef838 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -0,0 +1,761 @@ +// A chat's per-chat journal file from an earlier build is copied into the host's one database on +// that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that +// reappears after a downgrade is set aside on disk, and the chat keeps this build's history. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type * as NodeFs from 'node:fs' +import { existsSync, rmSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import type { SqliteRow } from '../../sqlite/sqlite-statement' +import Database from '../../sqlite/sync-database' +import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + liveTestJournalRows, + loadTestJournal, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' +import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' +import { importPerSessionJournal } from './journal-per-session-import' +import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, rmSync: vi.fn(actual.rmSync) } +}) + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-legacy', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +let root: string +let clock = 1_000 +const journals = createTrackedJournalOpener() + +function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } +} + +function legacyDir(): string { + return journalDirectoryFor(root, IDENTITY) +} + +/** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ +async function historyRows( + epoch = 'epoch-from-the-earlier-build', + reply = 'On it.' +): Promise<{ epoch: string; rows: JournalStoredRow[] }> { + const scratch = join(root, `scratch-${epoch}`) + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: scratch, + now: () => (clock += 1), + mintEpoch: () => epoch + }) + await journal.appendSubmission({ + clientMessageId: 'client-1', + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'add a retry' }] }, + fence: 1, + handoverRecorded: true + }) + await journal.appendItem( + item(1), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: reply }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const rows = readTestJournalRows( + openTestJournalHostDatabase(scratch).db, + IDENTITY.sessionId, + journal.epoch + ) + return { epoch: journal.epoch, rows } +} + +/** The per-chat file an earlier build left, in its own schema. */ +async function writeLegacyJournal(epoch: string, rows: readonly JournalStoredRow[]): Promise { + const path = legacyJournalDatabaseFile(legacyDir()) + await mkdir(dirname(path), { recursive: true }) + const db = new Database(path) + try { + db.pragma('journal_mode = WAL') + db.exec(` +CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, + ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, + content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) + db.pragma('user_version = 2') + const insert = db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + for (const row of rows) { + insert.run(IDENTITY.sessionId, row.epoch, row.seq, row.ts, row.rowJson) + } + if (rows.length > 0) { + db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(IDENTITY.sessionId, epoch, 1) + } + } finally { + db.close() + } +} + +/** Whatever is left of the chat's per-chat directory, or a copy of it, beside it. */ +async function leftovers(): Promise { + const parent = dirname(legacyDir()) + const name = legacyDir().slice(parent.length + 1) + return existsSync(parent) ? (await readdir(parent)).filter((entry) => entry.startsWith(name)) : [] +} + +function texts(journal: { snapshot: () => { items: { body: unknown }[] } }): string { + return JSON.stringify(journal.snapshot().items.map((entry) => entry.body)) +} + +function rowCount(db: Database.Database): number { + return Number(db.prepare('SELECT count(*) AS total FROM journal_rows').get()?.total) +} + +function removeFails(): void { + vi.mocked(rmSync).mockImplementation(() => { + throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + }) +} + +async function removeWorks(): Promise { + const actual = await vi.importActual('node:fs') + vi.mocked(rmSync).mockImplementation(actual.rmSync) +} + +/** The file as it is, except that the copy's first read of rows comes back through `alter`. */ +function alteringFirstCopiedRead( + alter: (rows: SqliteRow[]) => SqliteRow[] +): (path: string) => Database.Database { + return (path) => { + const source = new Database(path, { readonly: true, fileMustExist: true }) + const prepare = source.prepare.bind(source) + let altered = false + source.prepare = (sql: string) => { + const statement = prepare(sql) + if (altered || !sql.includes('seq > ?')) { + return statement + } + const all = statement.all.bind(statement) + // Why: prepare caches statements, so this one is handed out again after it was altered. + statement.all = (...args: Parameters) => { + const rows = all(...args) + if (altered) { + return rows + } + altered = true + return alter(rows) + } + return statement + } + return source + } +} + +const losingFirstCopiedRow = alteringFirstCopiedRead((rows) => rows.slice(1)) + +/** Every row still there and still parsing, but the reply's words changed. */ +const garblingCopiedReply = alteringFirstCopiedRead((rows) => + rows.map((row) => ({ ...row, row_json: String(row.row_json).replace('On it.', 'On in.') })) +) + +function openChat() { + return journals.open({ identity: IDENTITY, stateDirectory: root, now: () => (clock += 1) }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-per-session-import-')) + clock = 1_000 +}) + +afterEach(async () => { + vi.restoreAllMocks() + await removeWorks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('importing a per-chat journal', () => { + it('copies the history verbatim on first open and deletes the file', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + + const journal = await openChat() + + expect(journal.epoch).toBe(epoch) + expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) + expect( + readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) + ).toEqual(rows) + // Verified, then deleted with its WAL files: no copy of it is kept. + expect(await leftovers()).toEqual([]) + }) + + // T-B3: the upgrade restart is the restart that produced the offers. A new epoch or renumbered + // rows would silently withdraw every "resume after update" offer. + it('keeps a restart offer taken before the upgrade', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. + const marker = { + sessionId: IDENTITY.sessionId, + journalCursor: { epoch, sequence: rows.length } + } as AgentSessionResumeMarker + + const journal = await openChat() + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + sessions: new Map([[IDENTITY.sessionId, { journal, child: null }]]), + now: () => clock, + enqueue: (operation) => operation() + }) + + expect(withdrawal.movedOn(marker)).toBe(false) + }) + + it('copies in batches between turns of the event loop, and publishes the chat with the last', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const turns: { published: boolean; copied: number }[] = [] + let ticking = true + const tick = (): void => { + turns.push({ + published: readJournalSessionEpoch(database.db, IDENTITY.sessionId) !== null, + copied: rowCount(database.db) + }) + if (ticking) { + setImmediate(tick) + } + } + setImmediate(tick) + + await importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + batchRows: 1 + }) + ticking = false + + // Other work ran while rows were copied, and none of it could see a partly copied chat. + expect(turns.filter((turn) => !turn.published && turn.copied > 0).length).toBeGreaterThan(0) + expect(turns.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) + }) + + // A quit between two batches: no reader sees the rows copied so far, and the next open copies the + // chat again from the start, with no duplicate or leftover row. + it('copies a chat again cleanly after a copy stopped midway', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + setImmediate(() => closeTestJournalHostDatabases()) + + await expect( + importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + batchRows: 1 + }) + ).rejects.toMatchObject({ code: 'journal_closed' }) + const reopened = openTestJournalHostDatabase(root) + expect(rowCount(reopened.db)).toBe(1) + expect(readJournalSessionEpoch(reopened.db, IDENTITY.sessionId)).toBeNull() + expect(loadTestJournal(root, IDENTITY.sessionId)).toBeNull() + + const other = await journals.open({ + identity: { ...IDENTITY, sessionId: 'session-other' }, + stateDirectory: root + }) + await other.appendItem( + item(1), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'another chat' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const journal = await openChat() + + expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) + expect(readTestJournalRows(reopened.db, IDENTITY.sessionId, epoch)).toEqual(rows) + expect(rowCount(reopened.db)).toBe( + rows.length + liveTestJournalRows(reopened.db, 'session-other').length + ) + }) + + it('copies a chat once when two opens of it import at the same time', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const input = { database, identity: IDENTITY, legacyDirectory: legacyDir(), batchRows: 1 } + + const outcomes = await Promise.all([ + importPerSessionJournal(input), + importPerSessionJournal(input) + ]) + + expect(outcomes).toEqual(['imported', 'absent']) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) + expect(rowCount(database.db)).toBe(rows.length) + }) + + // Only the copy's own batches skip the fsync: a live chat's write between them, and the publish + // that makes the batches durable, commit fully synced. + it('commits copy batches unsynced, and every other commit synced', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const synchronous = () => Number(database.db.pragma('synchronous', { simple: true })) + const transaction = database.transaction.bind(database) + const commits: number[] = [] + vi.spyOn(database, 'transaction').mockImplementation((run) => + transaction((db) => { + commits.push(synchronous()) + return run(db) + }) + ) + const between: number[] = [] + let copying = true + const tick = (): void => { + // What a live chat's append would commit under, between two copy batches. + between.push(synchronous()) + if (copying) { + setImmediate(tick) + } + } + + setImmediate(tick) + await importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + batchRows: 1 + }) + copying = false + + // 2 is FULL, 1 is NORMAL. + expect(commits.slice(0, rows.length)).toEqual(rows.map(() => 1)) + expect(commits.at(-1)).toBe(2) + expect(between.length).toBeGreaterThan(0) + expect(between.every((value) => value === 2)).toBe(true) + expect(synchronous()).toBe(2) + }) + + it('ends a copy on a turn of its own, so the open that replays it starts a new task', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + let turns = 0 + const tick = (): void => { + turns += 1 + } + + setImmediate(tick) + await importPerSessionJournal({ + database: openTestJournalHostDatabase(root), + identity: IDENTITY, + legacyDirectory: legacyDir() + }) + + // One batch copies, and both verify reads are one batch each: only the final yield turns. + expect(turns).toBe(1) + }) + + it('keeps the file and refuses the chat when the copy does not read back as the file', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const input = { + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: losingFirstCopiedRow + } + const before = await readFile(legacyJournalDatabaseFile(legacyDir())) + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(importPerSessionJournal(input)).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + } + + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() + expect( + database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() + ).toMatchObject({ + total: 0 + }) + expect(errors).toHaveBeenCalledOnce() + // A copy that reads back whole then imports it, over what the refused ones left. + const journal = await openChat() + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) + expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) + expect(rowCount(database.db)).toBe(rows.length) + }) + + // A copy that keeps every count but not every byte is no copy: the file is all there is. + it('keeps the file and refuses the chat when a copied row differs but every count matches', async () => { + const { epoch, rows } = await historyRows() + expect(rows.filter((row) => row.rowJson.includes('On it.'))).toHaveLength(1) + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined) + const input = { + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: garblingCopiedReply + } + const before = await readFile(legacyJournalDatabaseFile(legacyDir())) + + await expect(importPerSessionJournal(input)).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() + expect(errors).toHaveBeenCalledOnce() + }) + + // T-R2B1: the copy committed and only the delete failed (a crash between them is the same). Rows appended since, a restart, and a + // reopen with the file still there: nothing is copied again, and nothing is lost. + it('never copies the same file again after a failed delete, and deletes it on the next open', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + removeFails() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const journal = await openChat() + await journal.appendItem( + item(2), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after the upgrade' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + // The process exits and the database closes. + await journals.closeAll() + await removeWorks() + + const reopened = await openChat() + expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 }) + expect(texts(reopened)).toContain('after the upgrade') + expect(await leftovers()).toEqual([]) + }) + + // T-import-transient: a read that fails leaves the file for the next open, which imports it. + it('leaves the file in place on a failed read, and refuses the open rather than serve it empty', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + const database = openTestJournalHostDatabase(root) + const io = Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) + + await expect( + importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: () => { + throw io + } + }) + ).rejects.toThrow(io) + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + expect( + database.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() + ).toMatchObject({ total: 0 }) + + const journal = await openChat() + expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) + expect(existsSync(legacyDir())).toBe(false) + }) + + // A file that is not a database stays where it is, and the chat says it cannot be loaded. + it('refuses the open of a legacy file that is not a database, and keeps the file', async () => { + await mkdir(legacyDir(), { recursive: true }) + await writeFile(legacyJournalDatabaseFile(legacyDir()), 'not a database '.repeat(512)) + + await expect(openChat()).rejects.toMatchObject({ errcode: 26 }) + + expect(await readdir(legacyDir())).toEqual(['journal.db']) + }) + + it('leaves a never-written file in place until the chat it belongs to is founded', async () => { + await writeLegacyJournal('unused', []) + await mkdir(legacyDir(), { recursive: true }) + await writeFile(join(legacyDir(), 'log.jsonl'), '{"kind":"epoch","v":1,"seq":1}\n', 'utf8') + + const journal = await openChat() + + // The pre-SQLite transcript beside it is still there to explain the empty chat. + expect(JSON.stringify(journal.snapshot().items)).toContain('log.jsonl') + await journals.closeAll() + await openChat() + // The journal file goes; the transcript is the user's, and stays. + expect(await readdir(legacyDir())).toEqual(['log.jsonl']) + }) + + // A crash between creating the file and giving it the schema: the chat opens with no history, as + // it did when each chat opened its own file, and the file is deleted like any never-written one. + it.each([ + ['empty', async (path: string) => writeFile(path, '')], + [ + 'schema-less', + async (path: string) => { + const db = new Database(path) + db.pragma('journal_mode = WAL') + db.close() + } + ] + ])('opens a chat whose per-chat file is %s as having no history', async (_fileState, create) => { + await mkdir(legacyDir(), { recursive: true }) + await create(legacyJournalDatabaseFile(legacyDir())) + + const journal = await openChat() + + expect(journal.snapshot().items).toEqual([]) + await journals.closeAll() + await openChat() + expect(await leftovers()).toEqual([]) + }) + + // F-L6-1: the copy deleted the file, so the older build found none and started the chat over. + // The re-upgrade must neither let that start replace the history nor delete what it wrote. + it('keeps the history, and the older build’s file untouched, after a downgrade round trip', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + expect(texts(await openChat())).toContain('ORIGINAL HISTORY') + await journals.closeAll() + expect(existsSync(legacyDir())).toBe(false) + const older = await historyRows('epoch-older-fresh', 'typed in the older build') + await writeLegacyJournal(older.epoch, older.rows) + const file = legacyJournalDatabaseFile(legacyDir()) + const bytes = await readFile(file) + + for (let open = 0; open < 2; open += 1) { + const reopened = await openChat() + expect(reopened.epoch).toBe(first.epoch) + expect(texts(reopened)).toContain('ORIGINAL HISTORY') + expect(texts(reopened)).not.toContain('typed in the older build') + await journals.closeAll() + } + const database = openTestJournalHostDatabase(root) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, first.epoch)).toEqual(first.rows) + expect((await readFile(file)).equals(bytes)).toBe(true) + const kept = new Database(file, { readonly: true }) + const keptRows = kept.prepare('SELECT seq, ts, row_json FROM journal_rows ORDER BY seq').all() + kept.close() + expect(keptRows.map((row) => row.row_json)).toEqual(older.rows.map((row) => row.rowJson)) + }) + + // The older build started over and then rewound, so its file's epoch opens `handle_forked`: it + // still never held this build's history, and is set aside like any other epoch. + it('keeps the history when the older build rewound the chat it started over', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + await openChat() + await journals.closeAll() + const older = await historyRows('epoch-older-rewound', 'typed in the older build') + const opening = JSON.parse(older.rows[0]!.rowJson) + expect(opening).toMatchObject({ kind: 'epoch', reason: 'session_created' }) + const rewound = [ + { ...older.rows[0]!, rowJson: JSON.stringify({ ...opening, reason: 'handle_forked' }) }, + ...older.rows.slice(1) + ] + await writeLegacyJournal(older.epoch, rewound) + const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) + + const reopened = await openChat() + + expect(reopened.epoch).toBe(first.epoch) + expect(texts(reopened)).toContain('ORIGINAL HISTORY') + expect(texts(reopened)).not.toContain('typed in the older build') + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) + }) + + // The one file at another epoch that did descend from the copy: its delete failed and the older + // build rolled the epoch of the file it kept. It is set aside too; nothing this build has is lost. + it('sets aside a kept file the older build rolled to a new epoch', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + removeFails() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await openChat() + await journals.closeAll() + await removeWorks() + const rolled = await historyRows('epoch-rolled-by-older', 'rolled in the older build') + await rm(legacyDir(), { recursive: true, force: true }) + await writeLegacyJournal(rolled.epoch, rolled.rows) + + const reopened = await openChat() + + expect(reopened.epoch).toBe(first.epoch) + expect(texts(reopened)).toContain('ORIGINAL HISTORY') + expect(texts(reopened)).not.toContain('rolled in the older build') + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + }) + + // A chat this build founded has a pointer and no import marker: an older build that then starts + // a per-chat file for it never held this build's history, so the file is set aside. + it('keeps a chat founded in this build when an older build starts a per-chat file for it', async () => { + const founded = await openChat() + await founded.appendItem( + item(1), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'FOUNDED HERE' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const foundedEpoch = founded.epoch + await journals.closeAll() + const older = await historyRows('epoch-older-fresh', 'typed in the older build') + await writeLegacyJournal(older.epoch, older.rows) + const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) + + const reopened = await openChat() + + expect(reopened.epoch).toBe(foundedEpoch) + expect(texts(reopened)).toContain('FOUNDED HERE') + expect(texts(reopened)).not.toContain('typed in the older build') + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) + }) + + // Decided once and recorded: no later open reads the file again, after a restart or after the + // older build ran again and wrote more to it. + it('never reads a set-aside file again', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + await openChat() + await journals.closeAll() + const older = await historyRows('epoch-older-fresh', 'typed in the older build') + await writeLegacyJournal(older.epoch, older.rows) + const reads: string[] = [] + const countingSource = (path: string) => { + reads.push(path) + return new Database(path, { readonly: true, fileMustExist: true }) + } + const importAgain = () => + importPerSessionJournal({ + database: openTestJournalHostDatabase(root), + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: countingSource + }) + + expect(await importAgain()).toBe('kept') + expect(reads).toHaveLength(1) + closeTestJournalHostDatabases() + expect(await importAgain()).toBe('kept') + const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } + const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) + legacy + .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') + .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) + legacy.close() + expect(await importAgain()).toBe('kept') + + expect(reads).toHaveLength(1) + expect(texts(await openChat())).toContain('ORIGINAL HISTORY') + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + }) + + // T-B5: a downgrade, an older build starting the chat over in a new per-chat file, and a + // re-upgrade — twice. This build's history stays whole each time, and the file stays on disk. + it('keeps this build’s history on every re-upgrade after an older build started the chat over', async () => { + const first = await historyRows('epoch-original', 'ORIGINAL HISTORY') + await writeLegacyJournal(first.epoch, first.rows) + const upgraded = await openChat() + await upgraded.appendItem( + item(2), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'this build' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journals.closeAll() + const older = await historyRows('epoch-downgrade', 'older build, cycle 1') + await writeLegacyJournal(older.epoch, older.rows) + + for (const cycle of [1, 2]) { + if (cycle === 2) { + // The second downgrade finds the file it wrote the first time, and carries it on. + const row = { ...JSON.parse(older.rows.at(-1)!.rowJson), seq: older.rows.length + 1 } + row.body = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'cycle 2' }] + } + const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) + legacy + .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') + .run(IDENTITY.sessionId, older.epoch, row.seq, 1, JSON.stringify(row)) + legacy.close() + } + const reopened = await openChat() + expect(reopened.epoch).toBe(first.epoch) + expect(texts(reopened)).toContain('ORIGINAL HISTORY') + expect(texts(reopened)).toContain('this build') + expect(texts(reopened)).not.toContain('older build, cycle 1') + expect(texts(reopened)).not.toContain('cycle 2') + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + await journals.closeAll() + } + }) + + // The delete failed, so the older build found the copied file and carried its epoch on, while + // this build wrote nothing past the copy. This build's history still wins: the file is set aside. + it('sets aside a file an older build carried on under the copied epoch', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + removeFails() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await openChat() + await journals.closeAll() + appendOlderRow(epoch, rows, rows.length + 1) + const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) + await removeWorks() + + const reopened = await openChat() + + expect(reopened.epoch).toBe(epoch) + expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length }) + expect(texts(reopened)).not.toContain('older') + expect(sharedRowsContaining('older')).toBe(0) + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) + }) + + /** The older build appends one row to the per-chat file the failed delete left, at its epoch. */ + function appendOlderRow(epoch: string, rows: readonly JournalStoredRow[], seq: number): void { + const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq } + olderRow.body = { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'older' }] + } + olderRow.itemId = `${olderRow.itemId}-older` + const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) + legacy + .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') + .run(IDENTITY.sessionId, epoch, seq, 1, JSON.stringify(olderRow)) + legacy.close() + } + + function sharedRowsContaining(text: string): number { + return openTestJournalHostDatabase(root) + .db.prepare('SELECT row_json FROM journal_rows') + .all() + .filter((row) => String(row.row_json).includes(text)).length + } +}) diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-import.ts b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts new file mode 100644 index 000000000000..5f85fdded437 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts @@ -0,0 +1,302 @@ +// Copying a chat's per-chat journal file into the host's one database, on that chat's open. +// +// Not `journal-legacy-import.ts`, which reads the PROVIDER's own transcript. This reads Orca's own +// earlier `/journal.db`, verbatim: the same epoch UUID and every sequence number, so a +// cursor, an `acceptedSequence` or a restart offer taken before the upgrade still points at the +// same row after it. A file that reappears after a downgrade is set aside, never read again (see +// journal-per-session-reimport.ts). +// +// The copy runs in bounded batches, each its own transaction, yielding the event loop between them. +// The rows go in under the file's epoch, which the chat's pointer does not name yet, so no reader +// sees them. Once they read back as the file does (every row's sequence, time and bytes), one +// transaction publishes the chat's pointer with its repair and import markers, so the chat is +// imported all at once or not at all. A try that stops midway leaves only unpublished rows, which +// the next try deletes before it copies again. A copy that does not read back as the file is never +// published: the file stays, and the chat is refused as unreadable. +// +// Only after that commit is the file deleted, its connection closed first. A read that fails +// leaves the file where it is for the next open, and the open is refused rather than served empty: +// an empty chat founded here would take a new epoch the next open's import could not reconcile. + +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' +import type { JournalLoad } from './journal-open' +import { JournalImportMismatchError, journalOpenRefusalError } from './journal-open-failure' +import { legacyJournalDatabaseFile } from './journal-paths' +import { + planPerSessionImport, + isPerSessionJournalSetAside, + readPerSessionImportMarker, + setAsidePerSessionJournal, + writePerSessionImportMarker, + type PerSessionImportPlan, + type PerSessionJournalHead +} from './journal-per-session-reimport' +import { + foldLegacyJournal, + IMPORT_BATCH_ROWS, + legacyRowBatches, + openLegacySource, + readLegacyHead, + readLegacyRepair, + retireLegacyJournal, + type ImportBatch +} from './journal-per-session-source' +import { parseJournalRow } from './journal-row-schema' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { + deleteUnpublishedJournalRows, + publishJournalSessionEpoch, + readJournalRowsAfter, + readJournalSessionEpoch +} from './journal-row-table' + +const INSERT_ROW = + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' +const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) +VALUES (?, ?, ?, ?) +ON CONFLICT(session_id) DO UPDATE SET + epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at` + +type PerSessionJournalImportDeps = { + openSource?: (path: string) => Database.Database + /** Deletes one of the per-chat files. */ + remove?: (path: string) => void + batchRows?: number +} + +export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' | 'kept' + +type ImportInput = { + database: JournalHostDatabase + identity: AgentSessionJournalIdentity + legacyDirectory: string +} & PerSessionJournalImportDeps + +/** Imports in flight, by database and chat: a second open of the same chat waits for the first. */ +const importsInFlight = new WeakMap>>() + +export function importPerSessionJournal( + input: ImportInput +): Promise { + let inFlight = importsInFlight.get(input.database) + if (!inFlight) { + inFlight = new Map() + importsInFlight.set(input.database, inFlight) + } + const { sessionId } = input.identity + const run = (inFlight.get(sessionId) ?? Promise.resolve()).then(() => importOnce(input)) + const settled = run.catch(() => undefined) + inFlight.set(sessionId, settled) + void settled.then(() => { + if (inFlight.get(sessionId) === settled) { + inFlight.delete(sessionId) + } + }) + return run +} + +async function importOnce(input: ImportInput): Promise { + const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) + if (!existsSync(sourcePath)) { + return 'absent' + } + const { sessionId } = input.identity + if (isPerSessionJournalSetAside(input.database.db, sessionId)) { + return 'kept' + } + const published = readJournalSessionEpoch(input.database.db, sessionId) !== null + const source = (input.openSource ?? openLegacySource)(sourcePath) + let legacy: PerSessionJournalHead | null + let plan: PerSessionImportPlan | null = null + try { + legacy = readLegacyHead(source, sessionId) + if (legacy) { + plan = planPerSessionImport({ db: input.database.db, sessionId, legacy, published }) + if (plan.kind === 'first') { + await copyLegacyJournal(input, source, legacy) + } + } + } finally { + source.close() + } + if (!legacy) { + // Never written. Left in place while its chat is unfounded: that open's empty chat may still + // owe the notice about a pre-SQLite transcript beside it. + if (!published) { + return 'absent' + } + retireLegacyJournal(input.legacyDirectory, input.remove) + return 'already-imported' + } + if (plan?.kind === 'kept') { + setAsidePerSessionJournal(input.database.db, sessionId, legacy) + return 'kept' + } + // Also a file a crash left after its copy was recorded (`copied`): deleted now, not copied again. + retireLegacyJournal(input.legacyDirectory, input.remove) + if (plan?.kind === 'copied') { + return 'already-imported' + } + // The open's replay of what was just copied is a long task of its own; don't add this one to it. + await yieldToEventLoop() + return 'imported' +} + +/** + * The chat a first copy would import, folded straight from its per-chat file and copying nothing: + * for a restore, which must not import. Null when the open has to import now instead: the chat is + * already in the host's database or was copied before (the reimport rules decide), its file holds + * no chat, or its fold needs a repair written. The file is closed before this returns. + */ +export async function previewPerSessionJournal( + input: Pick +): Promise { + const { sessionId } = input.identity + const db = input.database.db + const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) + if ( + readJournalSessionEpoch(db, sessionId) !== null || + readPerSessionImportMarker(db, sessionId) || + !existsSync(sourcePath) + ) { + return null + } + const source = (input.openSource ?? openLegacySource)(sourcePath) + try { + const legacy = readLegacyHead(source, sessionId) + if (!legacy) { + return null + } + const loaded = await foldLegacyJournal(source, sessionId, legacy) + return loaded.corrupt || loaded.readOnly || loaded.truncateFrom !== undefined ? null : loaded + } finally { + source.close() + } +} + +/** + * Batches under the file's epoch, which no reader follows until the chat's pointer names it. Once + * the rows read back as the file does, one transaction publishes the pointer with the chat's repair + * marker and the import marker. + */ +async function copyLegacyJournal( + input: ImportInput, + source: Database.Database, + legacy: PerSessionJournalHead +): Promise { + const { sessionId } = input.identity + const { epoch } = legacy + const repair = readLegacyRepair(source, sessionId) + const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS + let first = true + for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) { + if (!first) { + await yieldToEventLoop() + } + // Unsynced: no reader follows these rows, and the publish's synced commit covers them. + input.database.unsyncedTransaction((db) => { + if (first) { + // What an earlier try that stopped midway left. + deleteUnpublishedJournalRows(db, sessionId) + } + const insert = db.prepare(INSERT_ROW) + for (const row of batch.rows) { + // Copied as stored: the bytes are the row, its epoch and sequence included. + insert.run(sessionId, epoch, row.seq, row.ts, row.rowJson) + } + }) + first = false + } + await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch) + input.database.transaction((db) => { + publishJournalSessionEpoch(db, input.identity, epoch) + if (repair) { + db.prepare(UPSERT_REPAIR).run( + sessionId, + repair.epoch, + repair.content_from, + repair.repaired_at + ) + } + writePerSessionImportMarker(db, sessionId, legacy) + }) +} + +/** Mismatches already logged, so a chat refused on every open logs once. */ +const loggedMismatches = new Set() + +/** + * The copied rows, read back from the host's database, against a second read of what was copied: + * the same rows, byte for byte, and the same epoch, tip, row count, items and submissions, or the + * copy is refused and never published. Both reads go a batch at a time, so no check holds the main + * thread longer than a copy batch does. + */ +async function verifyCopiedJournal( + input: ImportInput, + expected: Iterable, + epoch: string +): Promise { + const { sessionId } = input.identity + const want = await copyFacts(sessionId, expected) + const got = await copyFacts(sessionId, copiedBatches(input, epoch)) + if (want === got) { + return + } + const error = new JournalImportMismatchError( + `per-chat journal of ${sessionId} read back as ${got} after its copy, not ${want}` + ) + if (!loggedMismatches.has(`${sessionId}\n${want}\n${got}`)) { + loggedMismatches.add(`${sessionId}\n${want}\n${got}`) + console.error(`[agent-session-journal] ${error.message}; ${input.legacyDirectory} is kept`) + } + throw journalOpenRefusalError(error) +} + +/** Epoch, tip, row count, items, submissions and a digest of every row, folded a batch at a time. */ +async function copyFacts(sessionId: string, batches: Iterable): Promise { + const state = createJournalReducerState(sessionId, '') + const content = createHash('sha256') + let epoch: string | null = null + let tip = 0 + let rows = 0 + let first = true + for (const batch of batches) { + if (!first) { + await yieldToEventLoop() + } + first = false + rows += batch.rows.length + for (const row of batch.rows) { + tip = Math.max(tip, row.seq) + // Length-framed, so no two different rows hash the same stream. + content.update(`${row.seq}:${row.ts}:${row.rowJson.length}:`).update(row.rowJson) + const parsed = parseJournalRow(row.rowJson) + if (parsed.ok) { + epoch ??= parsed.row.epoch + applyJournalRow(state, parsed.row) + } + } + } + return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}:${content.digest('hex')}` +} + +function* copiedBatches(input: ImportInput, epoch: string): Generator { + const { sessionId } = input.identity + const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS + let afterSeq = Number.MIN_SAFE_INTEGER + for (;;) { + const rows = readJournalRowsAfter(input.database.db, sessionId, epoch, afterSeq, batchRows) + const lastSeq = rows.at(-1)?.seq + const last = rows.length < batchRows || lastSeq === undefined + yield { rows, last } + if (last) { + return + } + afterSeq = lastSeq + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts b/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts new file mode 100644 index 000000000000..61ddbfc98f1b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts @@ -0,0 +1,73 @@ +// A per-chat file that reappears after its chat was copied in: an older build, run after a +// downgrade, attached the chat and wrote its history there. +// +// `journal_imports` records which file each chat was copied from — its epoch and tip — in the +// transaction that publishes the verified copy. A file still at that epoch and tip was already +// copied (only its delete failed, or a crash came first, across any number of restarts): it is +// deleted, never copied again. This build's history always wins: once a chat has been copied or +// founded here, any other file is set aside, left on disk as it is, and recorded in +// `journal_set_aside`, so no later open reads it again. + +import type Database from '../../sqlite/sync-database' + +export type PerSessionJournalHead = { epoch: string; tip: number } + +const SELECT_MARKER = 'SELECT epoch, tip FROM journal_imports WHERE session_id = ?' +const UPSERT_MARKER = `INSERT INTO journal_imports (session_id, epoch, tip) VALUES (?, ?, ?) +ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, tip = excluded.tip` + +export function readPerSessionImportMarker( + db: Database.Database, + sessionId: string +): PerSessionJournalHead | null { + const row = db.prepare(SELECT_MARKER).get(sessionId) + return typeof row?.epoch === 'string' && typeof row.tip === 'number' + ? { epoch: row.epoch, tip: row.tip } + : null +} + +const SELECT_SET_ASIDE = 'SELECT 1 AS present FROM journal_set_aside WHERE session_id = ?' +const INSERT_SET_ASIDE = `INSERT INTO journal_set_aside (session_id, epoch, tip) VALUES (?, ?, ?) +ON CONFLICT(session_id) DO NOTHING` + +export function isPerSessionJournalSetAside(db: Database.Database, sessionId: string): boolean { + return db.prepare(SELECT_SET_ASIDE).get(sessionId) !== undefined +} + +/** Records a file that is not this build's history, as it was when set aside. */ +export function setAsidePerSessionJournal( + db: Database.Database, + sessionId: string, + head: PerSessionJournalHead +): void { + db.prepare(INSERT_SET_ASIDE).run(sessionId, head.epoch, head.tip) +} + +export function writePerSessionImportMarker( + db: Database.Database, + sessionId: string, + head: PerSessionJournalHead +): void { + db.prepare(UPSERT_MARKER).run(sessionId, head.epoch, head.tip) +} + +export type PerSessionImportPlan = + | { kind: 'first' } + | { kind: 'copied' } + /** Not this build's history: set aside, neither copied nor deleted. */ + | { kind: 'kept' } + +/** What a present per-chat file owes this chat, judged against what was last copied from it. */ +export function planPerSessionImport(input: { + db: Database.Database + sessionId: string + legacy: PerSessionJournalHead + /** The chat already has an epoch in the host's database. */ + published: boolean +}): PerSessionImportPlan { + const marker = readPerSessionImportMarker(input.db, input.sessionId) + if (marker?.epoch === input.legacy.epoch && marker.tip === input.legacy.tip) { + return { kind: 'copied' } + } + return !marker && !input.published ? { kind: 'first' } : { kind: 'kept' } +} diff --git a/src/main/native-chat/agent-session-journal/journal-per-session-source.ts b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts new file mode 100644 index 000000000000..dc548e2f3dd0 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts @@ -0,0 +1,144 @@ +// Reading a chat's per-chat journal file, the one each chat had before the host's one database. +// The importer copies through this reader, and a restore folds through it without copying. + +import { rmdirSync, rmSync } from 'node:fs' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import Database from '../../sqlite/sync-database' +import type { SqliteRow } from '../../sqlite/sqlite-statement' +import { startJournalRowFold, type JournalLoad } from './journal-open' +import { legacyJournalDatabaseFile } from './journal-paths' +import type { PerSessionJournalHead } from './journal-per-session-reimport' +import { pendingJournalRepairSequence } from './journal-repair-marker' + +/** The newest per-chat file shape any build wrote. */ +const LEGACY_JOURNAL_SCHEMA_VERSION = 2 +/** Rows per batch: at most 31 ms per batch copying the largest real chat (68 MB, 3.3 KB rows). */ +export const IMPORT_BATCH_ROWS = 512 + +const SELECT_LEGACY_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' +const SELECT_LEGACY_TIP = + 'SELECT max(seq) AS tip FROM journal_rows WHERE session_id = ? AND epoch = ?' +const SELECT_LEGACY_ROWS = `SELECT seq, ts, row_json FROM journal_rows +WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC LIMIT ?` +const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = ?" +const SELECT_LEGACY_REPAIR = + 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?' + +type ImportedRow = { seq: number; ts: number; rowJson: string } +export type ImportBatch = { rows: ImportedRow[]; last: boolean } + +/** A plain read-only connection: it sees committed WAL frames without checkpointing them. */ +export function openLegacySource(path: string): Database.Database { + const source = new Database(path, { readonly: true, fileMustExist: true }) + try { + const version = Number(source.pragma('user_version', { simple: true }) ?? 0) + if (version > LEGACY_JOURNAL_SCHEMA_VERSION) { + throw new Error(`per-chat journal ${path} uses schema ${version}, which no build wrote`) + } + return source + } catch (error) { + source.close() + throw error + } +} + +export function readLegacyHead( + source: Database.Database, + sessionId: string +): PerSessionJournalHead | null { + // Created but never given its schema (a crash between the two): no history, as an empty file. + if (!source.prepare(HAS_LEGACY_TABLE).get('journal_sessions')) { + return null + } + const epoch = source.prepare(SELECT_LEGACY_EPOCH).get(sessionId)?.epoch + if (typeof epoch !== 'string' || epoch.length === 0) { + return null + } + const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip + return { epoch, tip: typeof tip === 'number' ? tip : 0 } +} + +/** The file's rows, one bounded page per batch, read as each batch is written. */ +export function* legacyRowBatches( + source: Database.Database, + sessionId: string, + epoch: string, + batchRows: number +): Generator { + const select = source.prepare(SELECT_LEGACY_ROWS) + let afterSeq = Number.MIN_SAFE_INTEGER + for (;;) { + const rows = select + .all(sessionId, epoch, afterSeq, batchRows) + .map((row) => ({ seq: Number(row.seq), ts: Number(row.ts), rowJson: String(row.row_json) })) + const lastSeq = rows.at(-1)?.seq + const last = rows.length < batchRows || lastSeq === undefined + yield { rows, last } + if (last) { + return + } + afterSeq = lastSeq + } +} + +/** The per-chat repair marker, as stored; v1 files predate the table. */ +export function readLegacyRepair(source: Database.Database, sessionId: string): SqliteRow | null { + if (!source.prepare(HAS_LEGACY_TABLE).get('journal_repairs')) { + return null + } + return source.prepare(SELECT_LEGACY_REPAIR).get(sessionId) ?? null +} + +/** The chat as its file holds it, folded the way a replay folds the host's database. */ +export async function foldLegacyJournal( + source: Database.Database, + sessionId: string, + legacy: PerSessionJournalHead +): Promise { + const fold = startJournalRowFold({ + sessionId, + epoch: legacy.epoch, + repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs') + ? pendingJournalRepairSequence(source, sessionId, legacy.epoch) + : null + }) + let first = true + for (const batch of legacyRowBatches(source, sessionId, legacy.epoch, IMPORT_BATCH_ROWS)) { + // A batch per turn: a large chat's file read in one task holds up everything else at startup. + if (!first) { + await yieldToEventLoop() + } + first = false + if (!batch.rows.every(fold.add)) { + break + } + } + return fold.finish() +} + +/** + * Deletes the per-chat file and SQLite's WAL files beside it, then the directory if nothing else is + * in it: a pre-SQLite transcript there is the user's, and stays. Its connection must be closed. + * Best effort: the copy is committed, so a file left behind is deleted by the next open. + */ +export function retireLegacyJournal( + legacyDirectory: string, + remove: (path: string) => void = (path) => rmSync(path, { force: true }) +): void { + const file = legacyJournalDatabaseFile(legacyDirectory) + try { + // The database first: a WAL left without it is never read, but a database left without its WAL + // would read back short of the tip it was copied at, and be set aside rather than deleted. + for (const path of [file, `${file}-wal`, `${file}-shm`]) { + remove(path) + } + } catch (error) { + console.warn(`[agent-session-journal] deleting imported ${legacyDirectory} failed`, error) + return + } + try { + rmdirSync(legacyDirectory) + } catch { + // Not empty: something beside the journal is kept. + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts index d31d2569c184..d41e9b363ed2 100644 --- a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts @@ -15,7 +15,7 @@ import { buildJournalTombstoneRow, journalLifecycleBatchRowBuilder } from './journal-row-builders' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' // A row's producer is fixed by the write that created it. A revision naming no // producer — a settlement, a prompt answer, a reopen sweep — keeps it; a @@ -172,7 +172,7 @@ describe('producer inheritance across a reopen', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'root' } }, - journalDir: root, + stateDirectory: root, now: () => 1_000 }) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 3fc8c3737884..96b45c9d17cb 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -10,6 +10,7 @@ import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, AGENT_SESSION_OPERATION_FUTURE_SKEW_MS } from '../../../shared/agent-session-host-authority' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' import type { JournalSubmissionConsume } from './journal-store-contracts' @@ -51,7 +52,7 @@ export type JournalQueuedMessagesDeps = { sessionId: string now: () => number serialize: (run: () => Promise) => Promise - database: () => { db: Database.Database } + database: () => JournalHostDatabase readOnly: () => boolean state: () => JournalReducerState /** The journal's own commit notification. Every standalone draft-table @@ -228,19 +229,11 @@ export class JournalQueuedMessages { ): Promise { return this.deps.serialize(async () => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const { db } = this.deps.database() - db.exec('BEGIN IMMEDIATE') - let result: T - let retired: number - try { - result = run(db) + const { result, retired } = this.deps.database().transaction((db) => ({ + result: run(db), // Any draft write may take the last card a pause holds back. - retired = retireQueuePauseIfNothingHeld(db, this.pauseScope()) - db.exec('COMMIT') - } catch (error) { - db.exec('ROLLBACK') - throw error - } + retired: retireQueuePauseIfNothingHeld(db, this.pauseScope()) + })) if (changed(result) || retired > 0) { this.changeRevision++ this.deps.committed() @@ -327,7 +320,8 @@ export class JournalQueuedMessages { * no hook), then retention runs; a pause left holding back nothing retires. */ repairAndPrune(): Promise { - if (this.deps.readOnly()) { + // No draft, no work, and no write: a chat whose first-use copy is still owed stays uncopied. + if (this.deps.readOnly() || (this.list().length === 0 && this.pause() === null)) { return Promise.resolve() } const { sessionId } = this.deps diff --git a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts b/src/main/native-chat/agent-session-journal/journal-repair-marker.ts index f9ef09e688de..785339456712 100644 --- a/src/main/native-chat/agent-session-journal/journal-repair-marker.ts +++ b/src/main/native-chat/agent-session-journal/journal-repair-marker.ts @@ -14,6 +14,7 @@ // sequence owns the epoch and stops the retry. import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' import { deleteJournalRowSuffix } from './journal-row-table' const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?' @@ -47,7 +48,7 @@ export function clearJournalRepairMarker(db: Database.Database, sessionId: strin /** Drop the rejected suffix and record that it is owed, atomically. */ export function deleteJournalRepairedSuffix(input: { - db: Database.Database + database: JournalHostDatabase sessionId: string epoch: string /** First sequence of the rejected suffix. */ @@ -56,14 +57,9 @@ export function deleteJournalRepairedSuffix(input: { contentFrom: number now: number }): number { - input.db.exec('BEGIN IMMEDIATE') - try { - const deleted = deleteJournalRowSuffix(input.db, input.sessionId, input.epoch, input.fromSeq) - input.db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now) - input.db.exec('COMMIT') + return input.database.transaction((db) => { + const deleted = deleteJournalRowSuffix(db, input.sessionId, input.epoch, input.fromSeq) + db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now) return deleted - } catch (error) { - input.db.exec('ROLLBACK') - throw error - } + }) } diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts index aaac2bc1a201..2f28adf0d545 100644 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts @@ -15,7 +15,7 @@ import type { import { digestPayload } from './journal-payload-bounds' import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { classifyDispatchRejection } from '../../../shared/structured-agent-session-dispatch-rejection' @@ -49,7 +49,7 @@ const journals = createTrackedJournalOpener() async function open() { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}` }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts index 6ebc20e5a33a..623c97445a61 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts @@ -12,10 +12,13 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' import { parseJournalRow } from './journal-row-schema' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + updateTestJournalRowJson +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -62,7 +65,7 @@ const journals = createTrackedJournalOpener() const open = () => journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => ++clock, mintEpoch: () => 'epoch-1' }) @@ -118,20 +121,14 @@ describe('context facts on replayed turn rows', () => { { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() - const opened = openJournalDatabase(journalDatabaseFile(root)) + const opened = openTestJournalHostDatabase(root) try { - const stored: unknown = opened.db - .prepare('SELECT row_json FROM journal_rows WHERE seq = 2') - .get() - const rowJson = - typeof stored === 'object' && stored !== null && 'row_json' in stored ? stored.row_json : '' - const future = JSON.parse(String(rowJson)) + const rowJson = liveTestJournalRows(opened.db, IDENTITY.sessionId)[1]?.rowJson ?? '' + const future = JSON.parse(rowJson) future.body.contextUsage = { used: { kind: 'measured-later', tokens: 'many' } } - opened.db - .prepare('UPDATE journal_rows SET row_json = ? WHERE seq = 2') - .run(JSON.stringify(future)) + updateTestJournalRowJson(opened.db, IDENTITY.sessionId, 2, JSON.stringify(future)) } finally { - opened.db.close() + opened.close() } const reopened = await open() diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts index f49dda6dcb69..616f4b557562 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' -import { openJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows +} from './journal-host-database-test-support' // Which rows an older host can still read: only rows that carry a turn item // are stamped with the version it does not know, and the epoch row never is. @@ -34,7 +36,7 @@ describe('journal row schema versions', () => { providerHandle: { kind: 'codex', threadId: 'thread-1' } }, now: () => 1_000, - journalDir: join(root, 'session-1') + stateDirectory: join(root, 'session-1') }) const identity = { provider: 'orca' as const, clientMessageId: 'm1' } await journal.appendItem( @@ -48,12 +50,10 @@ describe('journal row schema versions', () => { { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await journal.close() - const opened = openJournalDatabase(journalDatabaseFile(join(root, 'session-1'))) + const opened = openTestJournalHostDatabase(join(root, 'session-1')) try { - const stored = opened.db - .prepare('SELECT row_json FROM journal_rows ORDER BY seq') - .all() - .map((row) => JSON.parse(String((row as { row_json: string }).row_json))) + const stored = liveTestJournalRows(opened.db, 'session-1') + .map((row) => JSON.parse(row.rowJson)) .map((row: { kind: string; v: number }) => [row.kind, row.v]) expect(stored).toEqual([ ['epoch', 2], @@ -61,7 +61,7 @@ describe('journal row schema versions', () => { ['item', 3] ]) } finally { - opened.db.close() + opened.close() } }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-row-table.ts b/src/main/native-chat/agent-session-journal/journal-row-table.ts index f07ad19d4d50..4665a0571150 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -1,40 +1,41 @@ // Every statement the journal issues against `journal_rows` / `journal_sessions`. // -// Each one is a prefix or range scan on the `(session_id, epoch, seq)` primary -// key; there is no secondary index, and no `max(seq)` tip query — replay folds -// the epoch to obtain `lastSequence`, so nothing needs the tip from SQL. -// Columns are always named: `SELECT *` is uncacheable and can drop a column. +// Rows are keyed by the chat's identity: `(session_id, epoch, seq)`, the table's primary key. A +// replay is one range scan of that key, and retiring an epoch deletes one range of it. Columns are +// always named: `SELECT *` is uncacheable and can drop a column. import type Database from '../../sqlite/sync-database' import { serializeJournalRow, type JournalRow } from './journal-row-schema' export type JournalStoredRow = { epoch: string; seq: number; ts: number; rowJson: string } -const SELECT_SESSION = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' -const UPSERT_SESSION = `INSERT INTO journal_sessions (session_id, epoch, updated_at) +const SELECT_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' +const PUBLISH_SESSION_EPOCH = `INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES (?, ?, ?) -ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, updated_at = excluded.updated_at` +ON CONFLICT(session_id) DO UPDATE SET + workspace_id = excluded.workspace_id, epoch = excluded.epoch` const INSERT_ROW = 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' -const SELECT_EPOCH_ROWS = `SELECT epoch, seq, ts, row_json FROM journal_rows -WHERE session_id = ? AND epoch = ? ORDER BY seq ASC` -const SELECT_ROWS_AFTER = `SELECT epoch, seq, ts, row_json FROM journal_rows +const SELECT_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?' +const DELETE_EPOCH = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ?' +const DELETE_UNPUBLISHED = `DELETE FROM journal_rows WHERE session_id = ? +AND epoch IS NOT (SELECT epoch FROM journal_sessions WHERE session_id = ?)` export function readJournalSessionEpoch(db: Database.Database, sessionId: string): string | null { - const row = db.prepare(SELECT_SESSION).get(sessionId) as { epoch?: string } | undefined - return row?.epoch ?? null + const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch + return typeof epoch === 'string' ? epoch : null } -export function upsertJournalSessionRow( +/** Points the chat at `epoch`. Only an epoch change writes this row. */ +export function publishJournalSessionEpoch( db: Database.Database, - sessionId: string, - epoch: string, - updatedAt: number + identity: { sessionId: string; workspaceId: string }, + epoch: string ): void { - db.prepare(UPSERT_SESSION).run(sessionId, epoch, updatedAt) + db.prepare(PUBLISH_SESSION_EPOCH).run(identity.sessionId, identity.workspaceId, epoch) } export function insertJournalRow( @@ -47,20 +48,12 @@ export function insertJournalRow( return Buffer.byteLength(rowJson, 'utf8') } -export function readJournalEpochRows( - db: Database.Database, - sessionId: string, - epoch: string -): JournalStoredRow[] { - return toStoredRows(db.prepare(SELECT_EPOCH_ROWS).all(sessionId, epoch)) -} - // Why pages, not `.iterate()`: a lazily consumed cursor pins a read snapshot for as long as the // consumer reduces, and a WAL checkpoint cannot pass an open snapshot. Each page is one completed // statement, so the consumer's memory is bounded by a page while no snapshot outlives a fetch. const EPOCH_ROW_PAGE_SIZE = 128 -/** Epoch rows in sequence order, fetched one completed statement at a time. */ +/** The epoch's rows in sequence order, fetched one completed statement at a time. */ export function* iterateJournalEpochRows( db: Database.Database, sessionId: string, @@ -85,22 +78,29 @@ export function readJournalRowsAfter( afterSeq: number, limit?: number ): JournalStoredRow[] { - if (limit !== undefined) { - return toStoredRows( - db.prepare(SELECT_ROWS_AFTER_LIMITED).all(sessionId, epoch, afterSeq, limit) - ) - } - return toStoredRows(db.prepare(SELECT_ROWS_AFTER).all(sessionId, epoch, afterSeq)) + const rows = + limit !== undefined + ? db.prepare(SELECT_ROWS_AFTER_LIMITED).all(sessionId, epoch, afterSeq, limit) + : db.prepare(SELECT_ROWS_AFTER).all(sessionId, epoch, afterSeq) + return rows.flatMap((row) => + typeof row.seq === 'number' && typeof row.ts === 'number' && typeof row.row_json === 'string' + ? [{ epoch, seq: row.seq, ts: row.ts, rowJson: row.row_json }] + : [] + ) } -/** - * Unqualified on purpose. One database per session means every row here belongs - * to this session, and the unqualified form takes SQLite's truncate - * optimization: measured at 0.26% of the database in WAL bytes where the - * `WHERE session_id = ?` form rewrote every emptied leaf at up to 99%. - */ -export function deleteAllJournalRows(db: Database.Database): void { - db.exec('DELETE FROM journal_rows') +/** Every row of a retired epoch, in the transaction that retires it. */ +export function deleteJournalEpochRows( + db: Database.Database, + sessionId: string, + epoch: string +): void { + db.prepare(DELETE_EPOCH).run(sessionId, epoch) +} + +/** Rows of this chat under any epoch its pointer does not name: a copy that never published. */ +export function deleteUnpublishedJournalRows(db: Database.Database, sessionId: string): void { + db.prepare(DELETE_UNPUBLISHED).run(sessionId, sessionId) } /** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */ @@ -113,10 +113,3 @@ export function deleteJournalRowSuffix( const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq) return Number(deleted.changes ?? 0) } - -function toStoredRows(rows: readonly unknown[]): JournalStoredRow[] { - return rows.map((entry) => { - const record = entry as { epoch: string; seq: number; ts: number; row_json: string } - return { epoch: record.epoch, seq: record.seq, ts: record.ts, rowJson: record.row_json } - }) -} diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts index dc08739a8bc7..aa0078b88a59 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts @@ -9,15 +9,15 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' -import { - insertJournalRow, - readJournalEpochRows, - upsertJournalSessionRow -} from './journal-row-table' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalRow } from './journal-row-schema' import { JournalRowWriter } from './journal-row-writer' +import { + openTestJournalHostDatabase, + readTestJournalRows, + insertTestJournalRow, + publishTestJournalEpoch +} from './journal-host-database-test-support' const SESSION_ID = 'session-1' const EPOCH = 'epoch-1' @@ -38,19 +38,19 @@ function row(seq: number, ts: number): JournalRow { describe('journal row writer', () => { let root: string - let database: OpenJournalDatabase + let database: JournalHostDatabase let readOnly = false beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-')) - database = openJournalDatabase(journalDatabaseFile(root)) - upsertJournalSessionRow(database.db, SESSION_ID, EPOCH, 1) + database = openTestJournalHostDatabase(root) + publishTestJournalEpoch(database.db, SESSION_ID, EPOCH) readOnly = false }) afterEach(async () => { try { - database.db.close() + database.close() } catch { // Already closed by the case. } @@ -79,13 +79,13 @@ describe('journal row writer', () => { it('rolls the transaction back and sets no latch when the insert fails', async () => { const { writer, committedRows } = writerHarness() // A row already occupies sequence 1, so the insert violates the primary key. - insertJournalRow(database.db, SESSION_ID, row(1, 1)) + insertTestJournalRow(database.db, SESSION_ID, row(1, 1)) await expect(writer.enqueue(row)).rejects.toThrow() expect(readOnly).toBe(false) expect(committedRows).toHaveLength(0) - expect(readJournalEpochRows(database.db, SESSION_ID, EPOCH)).toHaveLength(1) + expect(readTestJournalRows(database.db, SESSION_ID, EPOCH)).toHaveLength(1) // Still writable: there is no ambiguity for a latch to protect against. await expect(writer.enqueue((seq, ts) => row(seq + 1, ts))).resolves.toMatchObject({ kind: 'item' diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index b45a841d26e2..a29585580ebe 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -1,5 +1,6 @@ import type Database from '../../sqlite/sync-database' -import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table' +import { insertJournalRow } from './journal-row-table' +import type { JournalHostDatabase } from './journal-host-database' import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' import type { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' @@ -13,7 +14,7 @@ export type JournalRowWriterDeps = { sessionId: string now: () => number serialize: (run: () => Promise) => Promise - database: () => { db: Database.Database } + database: () => JournalHostDatabase readOnly: () => boolean highestFence: () => number nextSequence: () => number @@ -39,16 +40,14 @@ export class JournalRowWriter { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) const row = build(this.deps.nextSequence(), this.deps.now()) assertJournalFence(row.fence, this.deps.highestFence()) - const { db } = this.deps.database() - db.exec('BEGIN IMMEDIATE') try { - insertJournalRow(db, this.deps.sessionId, row) - upsertJournalSessionRow(db, this.deps.sessionId, row.epoch, row.ts) - hook?.(db, row) - this.runBookkeeping(db, row) - db.exec('COMMIT') + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => { + insertJournalRow(db, this.deps.sessionId, row) + hook?.(db, row) + this.runBookkeeping(db, row) + }) } catch (error) { - db.exec('ROLLBACK') this.deps.rolledBack?.() throw error } diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts b/src/main/native-chat/agent-session-journal/journal-store-close.test.ts deleted file mode 100644 index 5ab255749c10..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// `close()`: enqueue-time admission, and the fulfilled/rejected split. -// -// The two failures this file exists to prevent: an append enqueued in the same -// turn as `close()` being rejected AFTER the queue accepted it, and a rejected -// close leaving a connection live but permanently unreachable through the API. - -import { access, 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 { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { journalDatabaseFile } from './journal-paths' -import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } -} - -let root: string -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function body(value: string): AgentJournalItemBody { - return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } -} - -function openJournal(): Promise { - return journals.open({ identity: IDENTITY, journalDir: root }) -} - -/** Replaces the store's own release step, which is the only step that can - * reject the attempt in production. */ -function injectReleaseFailure(journal: AgentSessionJournal): { - calls: () => number - stopFailing: () => void -} { - const internals = journal as unknown as { database: { db: { close: () => void } } } - const release = internals.database.db.close.bind(internals.database.db) - let calls = 0 - let failing = true - internals.database.db.close = () => { - calls += 1 - if (failing) { - throw new Error('injected release failure') - } - release() - } - return { calls: () => calls, stopFailing: () => (failing = false) } -} - -async function exists(path: string): Promise { - return access(path) - .then(() => true) - .catch(() => false) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-close-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('closed-state admission happens at enqueue', () => { - it('completes a write enqueued in the same turn as the close', async () => { - const journal = await openJournal() - const append = journal.appendItem(item(1), body('before'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const closed = journal.close() - - await expect(append).resolves.toBeDefined() - await expect(closed).resolves.toBeUndefined() - const reopened = await openJournal() - expect(reopened.snapshot().items).toHaveLength(1) - }) - - it('refuses a write offered while the close is still in flight, without queueing it', async () => { - const journal = await openJournal() - const closing = journal.close() - const refused = journal.appendItem(item(1), body('during'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - - // The rejection is available before the close step has run: it never joined - // the queue, so nothing is ever chained behind a close. - await expect(refused).rejects.toMatchObject({ code: 'journal_closed' }) - await expect(closing).resolves.toBeUndefined() - }) - - it('refuses every write entry point after the close has settled', async () => { - const journal = await openJournal() - await journal.close() - const settle = (attempt: Promise): Promise => - attempt.then( - () => new Error('resolved instead of refusing'), - (error: unknown) => error - ) - const refusals = [ - settle( - journal.appendItem(item(1), body('after'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ), - settle(journal.appendTombstone(item(3), { fence: 1 })), - settle( - journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: 'f', - body: { kind: 'message', role: 'user', blocks: [] }, - fence: 1 - }) - ), - settle( - journal.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('hostFault'), { - surface: 'rejection' - }), - fence: 1 - }) - ), - settle( - journal.appendLifecycleBatch({ - settlementId: 'settle', - fence: 1, - mutations: [ - { - kind: 'item', - identity: item(4), - body: body('x'), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ] - }) - ), - settle(journal.rollEpoch('handle_forked', 1)), - settle(journal.replaceEpochItems('handle_forked', 1, [])) - ] - for (const refusal of refusals) { - expect(await refusal).toMatchObject({ code: 'journal_closed' }) - } - }) - - // The property part 1 rests on: every entry point reaches the gate in the - // caller's own turn, so a refusal never advances the queue. - it('rejects without waiting for the queue to advance', async () => { - const journal = await openJournal() - const inFlight = journal.appendItem(item(1), body('admitted'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const closing = journal.close() - - // Settles while the admitted append is still running: it reached the gate in - // the caller's own turn and never joined the queue behind it. - await expect( - journal.appendItem(item(2), body('later'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ).rejects.toMatchObject({ - code: 'journal_closed' - }) - await expect(inFlight).resolves.toBeDefined() - await expect(closing).resolves.toBeUndefined() - }) - - it('resolves a second close as a no-op without running the routine again', async () => { - const journal = await openJournal() - const injected = injectReleaseFailure(journal) - injected.stopFailing() - await journal.close() - expect(injected.calls()).toBe(1) - - await expect(journal.close()).resolves.toBeUndefined() - expect(injected.calls()).toBe(1) - }) -}) - -describe('a rejected close is a real retry', () => { - it('retries the release, releases the handle, and then goes terminal', async () => { - const journal = await openJournal() - await journal.appendItem(item(1), body('durable'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const injected = injectReleaseFailure(journal) - - await expect(journal.close()).rejects.toThrow('injected release failure') - expect(injected.calls()).toBe(1) - - // Write-closed anyway: retry exists to release the OS handle, never to - // resurrect the store. - await expect( - journal.appendItem(item(2), body('after'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ).rejects.toMatchObject({ - code: 'journal_closed' - }) - - injected.stopFailing() - await expect(journal.close()).resolves.toBeUndefined() - // The retry RE-ENTERED the release. A completion flag on it would skip the - // one step that had not succeeded, and the handle would never be released. - expect(injected.calls()).toBe(2) - const dbPath = journalDatabaseFile(root) - expect(await exists(`${dbPath}-wal`)).toBe(false) - expect(await exists(`${dbPath}-shm`)).toBe(false) - - // Fulfilment is terminal: a third call must not issue a second db.close(), - // which `node:sqlite` answers with ERR_INVALID_STATE. - await expect(journal.close()).resolves.toBeUndefined() - expect(injected.calls()).toBe(2) - }) - - it('hands concurrent callers the same outcome', async () => { - const journal = await openJournal() - const injected = injectReleaseFailure(journal) - const first = journal.close() - const second = journal.close() - await expect(first).rejects.toThrow('injected release failure') - await expect(second).rejects.toThrow('injected release failure') - expect(injected.calls()).toBe(1) - }) -}) - -// The two rejected readings of the contract, as models, because a green run on -// the real store proves nothing about what the alternatives would have done. -describe('negative controls', () => { - class UnconditionalNoOpClose { - calls = 0 - private called = false - constructor(private readonly release: () => void) {} - async close(): Promise { - if (this.called) { - return - } - this.called = true - this.calls += 1 - this.release() - } - } - - class AlwaysReentrantClose { - calls = 0 - async close(release: () => void): Promise { - this.calls += 1 - release() - } - } - - it('an unconditional second-call no-op leaves a failed close unreleasable', async () => { - let failing = true - let released = false - const model = new UnconditionalNoOpClose(() => { - if (failing) { - throw new Error('injected release failure') - } - released = true - }) - await expect(model.close()).rejects.toThrow('injected release failure') - failing = false - await expect(model.close()).resolves.toBeUndefined() - // Fulfilled without the routine running: the handle is still held. - expect(model.calls).toBe(1) - expect(released).toBe(false) - }) - - it('an always-reentrant close issues the second db.close() that throws', async () => { - let open = true - const release = (): void => { - if (!open) { - throw new Error('ERR_INVALID_STATE: database is not open') - } - open = false - } - const model = new AlwaysReentrantClose() - await model.close(release) - await expect(model.close(release)).rejects.toThrow('database is not open') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-store-close.ts b/src/main/native-chat/agent-session-journal/journal-store-close.ts deleted file mode 100644 index f1b56d6a0db7..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-store-close.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Releasing the one SQLite connection a store holds for its lifetime. -// -// The contract, in five parts: -// -// 1. Closed-state admission happens at ENQUEUE, once, and is permanent. The -// flag lives on the store's write gate; it is never cleared, not by a -// rejection and not by a retry. A store that failed to close is still a -// store nobody may write to. -// 2. The close step shares the write queue and BYPASSES that gate — a close -// that consulted the flag it had just set would refuse itself. Same queue -// orders the release behind admitted work; separate gate lets it run. -// 3. One in-flight attempt, shared: concurrent callers get the same outcome. -// 4. Fulfilled is TERMINAL; rejected is not. A later call after fulfilment is a -// genuine no-op — `DatabaseSync.close()` throws `ERR_INVALID_STATE` on a -// second call, so re-entering after success is the bug, not the fix. -// 5. The release is deliberately unguarded. There is no way to ask whether a -// `db.close()` that threw released the handle first, and guarding the step -// would skip it on retry — guaranteeing a permanent leak in exactly the case -// where it did not release. - -import { AgentSessionJournalError } from './journal-write-guards' -import type Database from '../../sqlite/sync-database' - -/** - * The write queue and its closed gate. Admission is checked at ENQUEUE and is - * permanent; the close step reaches the same queue through `serializePastGate`, - * because a close that consulted the flag it had just set would refuse itself. - */ -export class JournalWriteQueue { - private writes: Promise = Promise.resolve() - private closed = false - - constructor(private readonly sessionId: string) {} - - markClosed(): void { - this.closed = true - } - - serialize(run: () => Promise): Promise { - if (this.closed) { - return Promise.reject( - new AgentSessionJournalError( - 'journal_closed', - `agent-session journal for ${this.sessionId} is closed` - ) - ) - } - return this.serializePastGate(run) - } - - serializePastGate(run: () => Promise): Promise { - const started = this.writes.then(run) - this.writes = started.catch(() => undefined) - return started - } -} - -export class JournalConnectionCloser { - private released = false - private inFlight: Promise | null = null - - constructor( - private readonly deps: { - connection: () => Database.Database | null - /** Chains onto the store's write queue past the closed gate. */ - enqueue: (run: () => Promise) => Promise - } - ) {} - - get isReleased(): boolean { - return this.released - } - - close(): Promise { - if (this.released) { - return Promise.resolve() - } - if (this.inFlight) { - return this.inFlight - } - const attempt = this.deps - .enqueue(() => this.release()) - .then( - () => { - this.released = true - this.inFlight = null - }, - (error: unknown) => { - this.inFlight = null - throw error - } - ) - this.inFlight = attempt - return attempt - } - - private async release(): Promise { - const db = this.deps.connection() - if (!db) { - return - } - // SQLite checkpoints and removes the WAL itself when the last connection to - // the database closes. - db.close() - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 42d167914ce0..16d3795cc6ab 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -7,7 +7,7 @@ import type { AgentJournalCursor, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { OpenJournalDatabase } from './journal-database' +import type { JournalHostDatabase } from './journal-host-database' import { JournalEpochController } from './journal-epoch-controller' import { JournalItemAppender } from './journal-item-appender' import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' @@ -25,19 +25,24 @@ export type JournalStoreHost = { * same way they learn of a row. */ notifyCommitted: () => void identity: AgentSessionJournalIdentity - journalDir: string + /** Where the chat's per-chat history lived, for the importer and the format-remnant notice. */ + legacyDirectory: string now: () => number mintEpoch: () => string serialize: (run: () => Promise) => Promise - database: () => OpenJournalDatabase + /** Leave a chat still in its per-chat file uncopied until its first use. */ + deferPerSessionImport: boolean + /** Work the chat's next write waits for. */ + owe: (work: () => Promise) => void + database: () => JournalHostDatabase state: () => JournalReducerState readOnly: () => boolean setReadOnly: (readOnly: boolean) => void cursor: () => AgentJournalCursor adopt: (loaded: JournalLoad) => void commit: (row: JournalRow) => void - /** A caller-supplied load, which suppresses replay entirely when present. */ - loaded: () => JournalLoad | null | undefined + /** Records whether the open's replay found an unusable prefix. */ + setOpenedCorrupt: (corrupt: boolean) => void malformedRows: () => number setMalformedRows: (count: number) => void journal: () => AgentSessionJournal diff --git a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts index dcb6a4bd5d1e..45d6f1efac26 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-contracts.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-contracts.ts @@ -9,17 +9,17 @@ import type { AgentJournalTurnScope, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { JournalLoad } from './journal-open' +import type { JournalHostDatabase } from './journal-host-database' import type { JournalLifecycleMutationInput } from './journal-row-builders' import type { JournalRow } from './journal-row-schema' export type AgentSessionJournalOptions = { identity: AgentSessionJournalIdentity - journalDir: string + database: JournalHostDatabase now?: () => number mintEpoch?: () => string - /** A caller that already loaded the journal can avoid reading the same files again. */ - loaded?: JournalLoad | null + /** A restore's open: see `AgentSessionJournal.whenImported`. */ + deferPerSessionImport?: boolean } export type JournalReadSince = diff --git a/src/main/native-chat/agent-session-journal/journal-store-open.ts b/src/main/native-chat/agent-session-journal/journal-store-open.ts index 7b5b6d0dff8f..a7163860be96 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-open.ts @@ -1,4 +1,3 @@ -import { mkdir } from 'node:fs/promises' import type { AgentJournalItemBody, AgentJournalItemIdentity @@ -16,10 +15,6 @@ import { staleSubagentRosterRevisions } from './journal-subagent-liveness' * pre-SQLite notice's. Same shape, and neither is only a repair. */ type JournalDisclosure = JournalRepairDisclosure -export async function ensureJournalDir(journalDir: string): Promise { - await mkdir(journalDir, { recursive: true }) -} - export function journalStoreLoadedFields(loaded: JournalLoad) { return { state: loaded.state, @@ -29,8 +24,7 @@ export function journalStoreLoadedFields(loaded: JournalLoad) { } export async function openJournalStoreState(input: { - journalDir: string - loaded: JournalLoad | null | undefined + legacyDirectory: string replay: () => JournalLoad | null /** Drops the rejected suffix and records the rebuild it owes, in ONE * transaction. Corruption is not preserved; replay keeps reporting `corrupt` @@ -52,7 +46,7 @@ export async function openJournalStoreState(input: { setMalformedRows: (count: number) => void readOnly: () => boolean }): Promise { - const loaded = input.loaded !== undefined ? input.loaded : input.replay() + const loaded = input.replay() if (!loaded) { input.start() await discloseFileFormatRemnant(input) @@ -96,7 +90,7 @@ export async function openJournalStoreState(input: { * Upserts by a constant identity, so the offer above is exactly-once in effect: * once the row exists the epoch is no longer empty. */ async function discloseFileFormatRemnant(input: { - journalDir: string + legacyDirectory: string agent: AgentType appendItem: ( identity: JournalDisclosure['identity'], @@ -109,7 +103,7 @@ async function discloseFileFormatRemnant(input: { if (input.readOnly()) { return } - const transcriptPath = findJournalFileFormatRemnant(input.journalDir) + const transcriptPath = findJournalFileFormatRemnant(input.legacyDirectory) if (!transcriptPath) { return } diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index 1b6f3a260a39..8fce8457b630 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-restore.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-restore.ts @@ -8,25 +8,51 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import type { JournalEpochController } from './journal-epoch-controller' -import { replayJournal } from './journal-open' +import { replayJournal, type JournalLoad } from './journal-open' +import { createJournalReducerState } from './journal-reducer' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' import { deleteJournalRepairedSuffix } from './journal-repair-marker' +import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' +import { AgentSessionJournalError } from './journal-write-guards' -export function restoreJournalStore( +export async function restoreJournalStore( host: JournalStoreHost, collaborators: { epochController: JournalEpochController } ): Promise { + const source = { + database: host.database(), + identity: host.identity, + legacyDirectory: host.legacyDirectory + } + if (source.database.readOnly) { + return restoreFromNewerDatabase(host, source) + } + // A restore reads a chat still in its per-chat file from there, and copies it before its first use. + const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null + if (preview) { + host.owe(async () => { + await importPerSessionJournal(source) + const imported = replayJournal(source.database.db, host.identity.sessionId) + if (!imported) { + throw new Error(`per-chat journal of ${host.identity.sessionId} was gone before its copy`) + } + host.adopt(imported) + }) + } else { + // A per-chat file left by an earlier build is this chat's newest history: copied in first. + await importPerSessionJournal(source) + } return openJournalStoreState({ - journalDir: host.journalDir, - loaded: host.loaded(), + legacyDirectory: host.legacyDirectory, replay: () => { - const opened = host.database() - return replayJournal(opened.db, opened.readOnly, host.identity.sessionId) + const loaded = preview ?? replayJournal(host.database().db, host.identity.sessionId) + host.setOpenedCorrupt(loaded?.corrupt ?? false) + return loaded }, deleteSuffix: (fromSeq, contentFrom) => deleteJournalRepairedSuffix({ - db: host.database().db, + database: host.database(), sessionId: host.identity.sessionId, epoch: host.state().epoch, fromSeq, @@ -50,3 +76,34 @@ export function restoreJournalStore( readOnly: host.readOnly }) } + +/** + * A newer Orca's database: each chat shows what this build can read of it, from the database or a + * per-chat file never copied in, and nothing is written, copied, repaired or founded. Every write + * the store is asked for is refused as read-only. + */ +async function restoreFromNewerDatabase( + host: JournalStoreHost, + source: Parameters[0] +): Promise { + const { sessionId } = host.identity + let loaded: JournalLoad | null + try { + loaded = + (await previewPerSessionJournal(source)) ?? replayJournal(source.database.db, sessionId) + } catch (error) { + // Tables the newer schema changed: still a chat only an update opens, not a damaged one. + throw new AgentSessionJournalError( + 'journal_read_only', + `agent-session journal for ${sessionId} could not be read under a newer schema`, + { cause: error } + ) + } + host.setOpenedCorrupt(false) + host.adopt({ + state: loaded?.state ?? createJournalReducerState(sessionId, ''), + readOnly: true, + corrupt: false, + malformedRows: 0 + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts index daa2e8196472..da6ffc84febc 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts @@ -6,7 +6,8 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa // // A newer build can change either alone, so both are needed. -import { mkdtemp, rm, stat } from 'node:fs/promises' +import { createHash } from 'node:crypto' +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' @@ -16,11 +17,15 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' -import { openJournalDatabase } from './journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalDatabaseFile } from './journal-paths' +import { journalDatabasePath } from './journal-host-database' import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + insertTestJournalRowJson +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -50,27 +55,47 @@ function body(value: string): AgentJournalItemBody { function open(): Promise { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}` }) } async function withDatabase(run: (db: Database.Database) => void): Promise { - const opened = openJournalDatabase(journalDatabaseFile(root)) + const opened = openTestJournalHostDatabase(root) try { run(opened.db) } finally { - opened.db.close() + opened.close() } } +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + /** Appends a raw `row_json` the way a newer build or a bad write would leave it. */ async function appendRawRow(epoch: string, seq: number, rowJson: string): Promise { await withDatabase((db) => { - db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ).run(IDENTITY.sessionId, epoch, seq, 1, rowJson) + expect(liveTestJournalRows(db, IDENTITY.sessionId)[0]?.epoch).toBe(epoch) + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, rowJson) + }) +} + +/** A row only a newer build could have written. */ +function futureRow(epoch: string, seq: number): string { + return JSON.stringify({ + v: 99, + kind: 'item', + epoch, + seq, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'from a newer build' } }) } @@ -85,7 +110,8 @@ afterEach(async () => { }) describe('axis 1: the database shape', () => { - it('latches read-only on a newer user_version and writes nothing', async () => { + // A newer build's database opens read-only: its chats read, and nothing here writes to it. + it('reads a database a newer build stamped, refuses every write, and writes nothing', async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -93,27 +119,54 @@ describe('axis 1: the database shape', () => { }) await journal.close() await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) - const before = await stat(journalDatabaseFile(root)) + const before = await digest(journalDatabasePath(root)) const reopened = await open() expect(reopened.isReadOnly).toBe(true) + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([body('a')]) await expect( reopened.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) ).rejects.toMatchObject({ code: 'journal_read_only' }) - // The file this build must not touch is byte-identical afterwards. - await reopened.close() - expect((await stat(journalDatabaseFile(root))).size).toBe(before.size) + // A chat the database never held opens empty rather than being founded, and refuses the same way. + const unwritten = await journals.open({ + identity: { ...IDENTITY, sessionId: 'session-2' }, + stateDirectory: root + }) + expect(unwritten.isReadOnly).toBe(true) + expect(unwritten.snapshot().items).toEqual([]) + await expect( + unwritten.appendItem(item(1), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toMatchObject({ + code: 'journal_read_only' + }) + await journals.closeAll() + expect(await digest(journalDatabasePath(root))).toBe(before) + }) + + // Tables a newer schema changed read as a chat only an update opens, not as damage. + it('refuses as read-only a chat whose tables a newer build changed', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.close() await withDatabase((db) => { - expect(db.pragma('user_version', { simple: true })).toBe(JOURNAL_DB_SCHEMA_VERSION + 1) + db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_v4') + db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) }) + + await expect(open()).rejects.toMatchObject({ code: 'journal_read_only' }) }) - it('refuses the schema escape hatch on a latched store', async () => { + it('refuses the schema escape hatch on a store latched by a newer row', async () => { const journal = await open() + const epoch = journal.epoch + const nextSeq = journal.cursor().sequence + 1 await journal.close() - await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) + await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) const reopened = await open() // With byte-copy quarantine gone there is nothing for `schema_unreadable` to @@ -124,7 +177,8 @@ describe('axis 1: the database shape', () => { expect(reopened.isReadOnly).toBe(true) }) - it('migrates an older user_version forward on reopen', async () => { + // The only older version brought forward; 1 and 2 are refused (journal-database.test.ts). + it('stamps the current user_version on a version-0 file that already has its tables', async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, @@ -153,21 +207,7 @@ describe('axis 2: the row body shape', () => { const epoch = journal.epoch const nextSeq = journal.cursor().sequence + 1 await journal.close() - await appendRawRow( - epoch, - nextSeq, - JSON.stringify({ - v: 99, - kind: 'item', - epoch, - seq: nextSeq, - fence: 1, - ts: 1, - itemId: 'future', - revision: 1, - body: { kind: 'status', text: 'from a newer build' } - }) - ) + await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) const reopened = await open() expect(reopened.isReadOnly).toBe(true) @@ -179,10 +219,8 @@ describe('axis 2: the row body shape', () => { await reopened.close() // Never skipped, never deleted: the row this build cannot read is still there. await withDatabase((db) => { - const stored = db.prepare('SELECT row_json FROM journal_rows WHERE seq = ?').get(nextSeq) as { - row_json: string - } - expect(stored.row_json).toContain('"v":99') + const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === nextSeq) + expect(stored?.rowJson).toContain('"v":99') }) }) diff --git a/src/main/native-chat/agent-session-journal/journal-store-test-open.ts b/src/main/native-chat/agent-session-journal/journal-store-test-open.ts deleted file mode 100644 index fc8bce5af391..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-store-test-open.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Shared tracked-open helper for journal tests. -// -// Tracking every opened INSTANCE rather than a variable is the point: `close()` -// is idempotent, a module-level `journal` binding can be reassigned to a second -// store mid-suite, and `allSettled` means one failing close cannot skip the rest -// or the directory removal behind it. - -import type { AgentSessionJournal } from './journal-store' -import type { AgentSessionJournalOptions } from './journal-store-contracts' -import { openAgentSessionJournal } from './journal-store-factory' - -export type TrackedJournalOpener = { - open: (options: AgentSessionJournalOptions) => Promise - track: (journal: T) => T - closeAll: () => Promise -} - -export function createTrackedJournalOpener(): TrackedJournalOpener { - const opened: AgentSessionJournal[] = [] - return { - open: async (options) => { - const journal = await openAgentSessionJournal(options) - opened.push(journal) - return journal - }, - track: (journal) => { - opened.push(journal) - return journal - }, - closeAll: async () => { - await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) - } - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-store.test.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index 67eb19f8cbe1..871aca7880dc 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.test.ts @@ -13,17 +13,21 @@ import { boundJournalKeyComponent, MAX_JOURNAL_KEY_COMPONENT_CHARS } from '../../../shared/agent-session-journal-item-key' -import { loadJournal } from './journal-open' import { boundInlineText, boundPayload, DEFAULT_JOURNAL_PAYLOAD_LIMITS } from './journal-payload-bounds' import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { journalDatabaseFile, journalDirectoryFor, journalPathSegment } from './journal-paths' +import { journalDirectoryFor, journalPathSegment } from './journal-paths' import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + deleteTestJournalRow +} from './journal-host-database-test-support' import type Database from '../../sqlite/sync-database' const IDENTITY: AgentSessionJournalIdentity = { @@ -55,7 +59,7 @@ const journals = createTrackedJournalOpener() async function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides @@ -236,20 +240,6 @@ describe('fences', () => { }) describe('replay', () => { - it('adopts a caller-provided load without replaying the rows again', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const loaded = await loadJournal(root, IDENTITY.sessionId) - expect(loaded).not.toBeNull() - await journal.close() - - const reopened = await open({ loaded }) - expect(reopened.snapshot()).toEqual(journal.snapshot()) - }) - it('reopens to the same render model the live writer held', async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { @@ -315,7 +305,7 @@ describe('replay', () => { const before = journal.epoch await journal.close() await withJournalDatabase(root, (db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(3) + deleteTestJournalRow(db, IDENTITY.sessionId, 3) }) const reopened = await open() @@ -324,8 +314,8 @@ describe('replay', () => { // Sequences 4 and 5 are VALID rows that the gap at 3 made unreplayable. // Nothing preserves them; recovery rebuilds the epoch from provider history. await withJournalDatabase(root, (db) => { - const rows = db.prepare('SELECT seq FROM journal_rows ORDER BY seq').all() - expect(rows.map((row) => (row as { seq: number }).seq)).toEqual([1, 2]) + const rows = liveTestJournalRows(db, IDENTITY.sessionId) + expect(rows.map((row) => row.seq)).toEqual([1, 2]) }) expect(reopened.repair).toEqual({ malformedRows: 0 }) }) @@ -476,17 +466,17 @@ describe('journal location', () => { }) describe('on-disk layout', () => { - it('keeps the session database and its projection in one directory', async () => { + it('keeps every chat of the state directory in its one database, and no per-chat file', async () => { const journal: AgentSessionJournal = await open() await journal.appendItem(item(0), body('a'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - expect(await readdir(root)).toContain('journal.db') + expect(await readdir(root)).toContain('agent-session-journal.db') + expect(await readdir(root)).not.toContain('agent-session-journal') await journal.close() await withJournalDatabase(root, (db) => { - const row = db.prepare('SELECT row_json FROM journal_rows WHERE seq = 2').get() - expect((row as { row_json: string }).row_json).toContain('"kind":"item"') + expect(liveTestJournalRows(db, IDENTITY.sessionId)[1]?.rowJson).toContain('"kind":"item"') expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toMatchObject({ epoch: journal.epoch }) @@ -497,15 +487,14 @@ describe('on-disk layout', () => { /** Opens the session database directly, so a case can stage a fault or read * back what a commit actually stored. */ async function withJournalDatabase( - journalDir: string, + stateDirectory: string, run: (db: Database.Database) => void ): Promise { - const { openJournalDatabase } = await import('./journal-database') - const opened = openJournalDatabase(journalDatabaseFile(journalDir)) + const opened = openTestJournalHostDatabase(stateDirectory) try { run(opened.db) } finally { - opened.db.close() + opened.close() } } diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 1f6015c39409..cf8b5e0f6eec 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -1,4 +1,5 @@ -// Append-only journal store for one agent session. +// Append-only journal store for one agent session. It owns the chat's fold and write queue, and no +// connection: every statement goes through the host's one journal database. import type { AgentJournalDispatchRejection } from '../../../shared/agent-session-failure-words' import { randomUUID } from 'node:crypto' @@ -23,12 +24,10 @@ import { liveStructuredAgentSessionTurnScope, newestStructuredAgentSessionTurnBySequence } from '../../../shared/structured-agent-session-live-turn' -import { agentSessionJournalCloseRetries } from './journal-close-retry' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' import type { JournalReplacementItem } from './journal-epoch-replacement' import { readJournalSince } from './journal-cursor' +import type { JournalHostDatabase } from './journal-host-database' import { readJournalRowsAfterCursor, type JournalLoad } from './journal-open' -import { journalDatabaseFile } from './journal-paths' import { markJournalPendingSubmissionsUnknown, rejectJournalPendingSubmissions, @@ -59,12 +58,11 @@ import type { } from './journal-store-contracts' import { queuedMessageConsumeHook, type JournalQueuedMessages } from './journal-queued-messages' import type { AgentJournalEpochReason } from './journal-row-schema' -import { AgentSessionJournalError } from './journal-write-guards' import type { JournalRowWriter } from './journal-row-writer' import type { JournalEpochController } from './journal-epoch-controller' -import { JournalConnectionCloser, JournalWriteQueue } from './journal-store-close' +import { JournalWriteQueue } from './journal-write-queue' import { createJournalStoreCollaborators } from './journal-store-collaborators' -import { ensureJournalDir, journalStoreLoadedFields } from './journal-store-open' +import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' @@ -72,20 +70,17 @@ export { AgentSessionJournalError } from './journal-write-guards' export class AgentSessionJournal { private readonly identity: AgentSessionJournalIdentity - private readonly journalDir: string - private readonly dbPath: string + private readonly database: JournalHostDatabase private readonly now: () => number private readonly mintEpoch: () => string - private readonly loaded: JournalLoad | null | undefined private state: JournalReducerState private readOnly = false private malformedRows = 0 + private openedCorrupt = false private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } - private database: OpenJournalDatabase | null = null private onCommitted: (() => void) | null = null private readonly queue: JournalWriteQueue - private readonly closer: JournalConnectionCloser private readonly rowWriter: JournalRowWriter private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender @@ -96,25 +91,21 @@ export class AgentSessionJournal { constructor(options: AgentSessionJournalOptions) { this.identity = options.identity - this.journalDir = options.journalDir - this.dbPath = journalDatabaseFile(options.journalDir) + this.database = options.database this.now = options.now ?? (() => Date.now()) this.mintEpoch = options.mintEpoch ?? randomUUID - this.loaded = options.loaded this.state = createJournalReducerState(options.identity.sessionId, '') // Serializes sequence assignment with the durable write behind it. this.queue = new JournalWriteQueue(options.identity.sessionId) - this.closer = new JournalConnectionCloser({ - connection: () => this.database?.db ?? null, - enqueue: (run) => this.queue.serializePastGate(run) - }) const collaborators = createJournalStoreCollaborators({ identity: this.identity, - journalDir: this.journalDir, + legacyDirectory: this.database.legacyDirectoryFor(this.identity), now: this.now, mintEpoch: this.mintEpoch, serialize: (run) => this.queue.serialize(run), - database: () => this.requireDatabase(), + deferPerSessionImport: options.deferPerSessionImport === true, + owe: (work) => this.queue.owe(work), + database: () => this.database, state: () => this.state, readOnly: () => this.readOnly, setReadOnly: (readOnly) => { @@ -129,8 +120,10 @@ export class AgentSessionJournal { applyJournalRow(this.state, row) this.onCommitted?.() }, + setOpenedCorrupt: (corrupt) => { + this.openedCorrupt = corrupt + }, notifyCommitted: () => this.onCommitted?.(), - loaded: () => this.loaded, malformedRows: () => this.malformedRows, setMalformedRows: (count) => { this.malformedRows = count @@ -154,10 +147,6 @@ export class AgentSessionJournal { return this.state.epoch } - get directory(): string { - return this.journalDir - } - /** Whether a row at this sequence was on disk when this handle opened, so an earlier handle * wrote it. Sequences restart with each epoch, so a row of a later epoch never was. */ wroteBeforeOpen(sequence: number | undefined): boolean { @@ -173,27 +162,21 @@ export class AgentSessionJournal { return { malformedRows: this.malformedRows } } + /** The open replayed an unusable prefix: the chat is owed a rebuild from provider history. */ + get needsRebuild(): boolean { + return this.openedCorrupt + } + async open(): Promise { - await ensureJournalDir(this.journalDir) - this.database = openJournalDatabase(this.dbPath) - try { - await this.restore() - this.openedThrough = this.cursor() - } catch (error) { - // Nothing else holds a reference to this connection, so a throw here is - // the leak site unless the store releases it itself — and a close that - // REJECTS has not released it, so the store is retained for a later retry - // instead of being dropped with its handle open. - await agentSessionJournalCloseRetries.closeOrRetain(this) - throw error - } + await this.restore() + this.openedThrough = this.cursor() } - /** Releases the session's SQLite handle. Idempotent on success, a real retry - * after a failure, and permanently closed to writes either way (§ close). */ + /** Refuses every later write and resolves once the admitted ones have landed. Holds no + * connection, so there is nothing to release and nothing that can fail. */ close(): Promise { this.queue.markClosed() - return this.closer.close() + return this.queue.drain() } /** Told of every durable change, epoch replacements included, so a reader learns of a write @@ -202,6 +185,19 @@ export class AgentSessionJournal { this.onCommitted = listener } + /** + * Resolves once the chat's rows are in the host's database. A restore's open serves a chat still + * in its per-chat file from a read-only fold of it; the copy runs before the chat's first write, + * and a reader that needs rows (forward pages, catch-up) awaits it here. + */ + whenImported(): Promise { + return this.queue.serialize(async () => undefined) + } + + get importPending(): boolean { + return this.queue.owing + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -269,7 +265,7 @@ export class AgentSessionJournal { state: this.state, rowsAfter: (afterSequence) => readJournalRowsAfterCursor( - this.requireDatabase().db, + this.database.db, this.identity.sessionId, this.state.epoch, afterSequence, @@ -373,14 +369,4 @@ export class AgentSessionJournal { private adoptLoadedJournal(loaded: JournalLoad): void { Object.assign(this, journalStoreLoadedFields(loaded)) } - - private requireDatabase(): OpenJournalDatabase { - if (!this.database) { - throw new AgentSessionJournalError( - 'journal_closed', - `agent-session journal for ${this.identity.sessionId} is not open` - ) - } - return this.database - } } diff --git a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts index 0f1490c3ca80..c000ffe9fbae 100644 --- a/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-streaming-replay.test.ts @@ -3,15 +3,19 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' -import { journalDatabaseFile } from './journal-paths' +import type { JournalHostDatabase } from './journal-host-database' import { replayJournal } from './journal-open' -import { insertJournalRow, upsertJournalSessionRow } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import * as reducer from './journal-reducer' +import { + openTestJournalHostDatabase, + insertTestJournalRow, + publishTestJournalEpoch, + insertTestJournalRowJson +} from './journal-host-database-test-support' let root: string -let opened: OpenJournalDatabase +let opened: JournalHostDatabase const sessionId = 'streaming-session' const epoch = 'epoch-1' @@ -43,18 +47,18 @@ function revision(seq: number, text = 'content'): JournalRow { } function put(row: JournalRow): void { - insertJournalRow(opened.db, sessionId, row) + insertTestJournalRow(opened.db, sessionId, row) } beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-stream-replay-')) - opened = openJournalDatabase(journalDatabaseFile(root)) - upsertJournalSessionRow(opened.db, sessionId, epoch, 1) + opened = openTestJournalHostDatabase(root) + publishTestJournalEpoch(opened.db, sessionId, epoch) }) afterEach(async () => { vi.restoreAllMocks() - opened.db.close() + opened.close() await rm(root, { recursive: true, force: true }) }) @@ -86,7 +90,7 @@ describe('streaming journal replay', () => { } apply(state, row) }) - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded.state.items.size).toBe(1) expect(loaded.state.items.get('message-1')?.revision).toBe(2049) expect(loaded.state.lastSequence).toBe(2049) @@ -108,7 +112,7 @@ describe('streaming journal replay', () => { } apply(state, row) }) - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded.state.lastSequence).toBe(300) expect(checkpoints.map((entry) => entry.busy)).toEqual([0, 0]) }) @@ -118,7 +122,7 @@ describe('streaming journal replay', () => { put(revision(2)) put(revision(4)) put({ ...revision(5), v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 }) - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ readOnly: true, corrupt: true, malformedRows: 0 }) expect(loaded.truncateFrom).toBeUndefined() expect(loaded.state.items.get('message-1')?.revision).toBe(2) @@ -131,10 +135,8 @@ describe('streaming journal replay', () => { put(anchor()) put(revision(2)) put(revision(4)) - opened.db - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(sessionId, epoch, 5, 5, '{') - const loaded = replayJournal(opened.db, false, sessionId)! + insertTestJournalRowJson(opened.db, sessionId, 5, '{') + const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ readOnly: false, corrupt: true, @@ -147,7 +149,7 @@ describe('streaming journal replay', () => { it('rejects an unanchored prefix before a later gap', () => { put(revision(1)) put(revision(3)) - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ readOnly: false, corrupt: true, truncateFrom: 1 }) expect(loaded.state.items.size).toBe(0) expect(loaded.state.lastSequence).toBe(0) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts index 1fea8b9efa52..6b519ce35cd8 100644 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts @@ -19,7 +19,7 @@ import { codexSubagentGroupIdentity } from '../../codex/codex-subagent-roster' import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' import { staleSubagentRosterRevisions } from './journal-subagent-liveness' const IDENTITY: AgentSessionJournalIdentity = { @@ -45,7 +45,7 @@ const journals = createTrackedJournalOpener() async function open(overrides: Partial[0]> = {}) { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts index 0d62ed1b2320..fc9431646158 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts @@ -14,7 +14,7 @@ import type { import { createJournalReducerState, applyJournalRow } from './journal-reducer' import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', @@ -36,7 +36,7 @@ const journals = createTrackedJournalOpener() function open(): Promise { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => ++clock, mintEpoch: () => `epoch-${clock}` }) diff --git a/src/main/native-chat/agent-session-journal/journal-write-guards.ts b/src/main/native-chat/agent-session-journal/journal-write-guards.ts index eeab16716495..10e709d174f7 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-guards.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-guards.ts @@ -10,9 +10,10 @@ export class AgentSessionJournalError extends Error { | 'journal_stale_fence' | 'journal_closed' | 'journal_submission_exists', - message: string + message: string, + options?: ErrorOptions ) { - super(message) + super(message, options) this.name = 'AgentSessionJournalError' } } diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts new file mode 100644 index 000000000000..13fd9e4b96c2 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -0,0 +1,61 @@ +// One chat's write queue: build, commit and adopt run one at a time, in arrival order. +// +// Closing is admission only. A closed store refuses new writes forever, so a stale reference +// (an event sink, a late callback) can never append from a fold a newer open has replaced; the +// writes already admitted still land, and `drain` resolves once they have. + +import { AgentSessionJournalError } from './journal-write-guards' + +/** Admission is checked at ENQUEUE and is permanent. */ +export class JournalWriteQueue { + private writes: Promise = Promise.resolve() + private closed = false + /** Runs before the next write, and stays owed until it succeeds. */ + private owed: (() => Promise) | null = null + + constructor(private readonly sessionId: string) {} + + markClosed(): void { + this.closed = true + } + + serialize(run: () => Promise): Promise { + if (this.closed) { + return Promise.reject( + new AgentSessionJournalError( + 'journal_closed', + `agent-session journal for ${this.sessionId} is closed` + ) + ) + } + return this.serializePastGate(run) + } + + owe(work: () => Promise): void { + this.owed = work + } + + get owing(): boolean { + return this.owed !== null + } + + private payOwed = async (): Promise => { + const owed = this.owed + if (owed) { + await owed() + this.owed = null + } + } + + /** Resolves once every write admitted so far has settled, whatever its outcome. */ + drain(): Promise { + return this.writes.then(() => undefined) + } + + private serializePastGate(run: () => Promise): Promise { + // Only a write admitted while work is owed takes the extra step, so no other write's timing moves. + const started = this.owed ? this.writes.then(this.payOwed).then(run) : this.writes.then(run) + this.writes = started.catch(() => undefined) + return started + } +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts index 75b2e1ff65d3..638a62bdf02a 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts @@ -13,10 +13,13 @@ import type { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import Database from '../../sqlite/sync-database' -import { journalDatabaseFile } from './journal-paths' +import { journalDatabasePath } from './journal-host-database' import { JournalQueuedMessages } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', @@ -45,7 +48,7 @@ const journals = createTrackedJournalOpener() function open(): Promise { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => ++clock, mintEpoch: () => `epoch-${clock}` }) @@ -120,7 +123,9 @@ describe('draft bookkeeping inside a journal append', () => { it('an older draft table without a later column is healed at open, so a refusal returns the card', async () => { const first = await open() await first.close() - const db = new Database(journalDatabaseFile(root)) + // The table is healed when the host opens its database, so the host restarts around the edit. + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) db.exec('DROP TABLE queued_messages') // The shape an earlier build of the draft table wrote: no returned_rejection. db.exec(`CREATE TABLE queued_messages ( diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts index 376d3145a9d1..5b21c36f3af5 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts @@ -16,7 +16,7 @@ import { agentSessionFailureWords } from '../../../shared/agent-session-failure- import { queuedMessageFingerprint } from '../agent-session-wire/structured-agent-session-queued-messages' import { JournalQueuedMessages } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { createTrackedJournalOpener } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', @@ -59,7 +59,7 @@ async function withdrawnDraft( function open(): Promise { return journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => ++clock, mintEpoch: () => `epoch-${clock}` }) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts index 4f9b6f81e030..0e42a22be4d9 100644 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts @@ -14,14 +14,18 @@ import type { import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import Database from '../../sqlite/sync-database' -import { journalDatabaseFile } from './journal-paths' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' import { JournalQueuedMessages, QUEUED_MESSAGE_REPLAY_WINDOW_MS, QueuedMessageNotConsumableError } from './journal-queued-messages' import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-q', @@ -64,7 +68,7 @@ function refusal(text: string) { async function open(): Promise { const journal = await journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => `epoch-${clock}` }) @@ -117,12 +121,12 @@ describe('draft rows', () => { const journal = await open() await queueDraft(journal, 'draft-1') await journal.close() - const db = new Database(journalDatabaseFile(root), { readonly: true }) + const db = new Database(journalDatabasePath(root), { readonly: true }) try { const version = Number(db.pragma('user_version', { simple: true })) // An old build compares stored == supported and keeps writing; a bump // would latch it read-only after downgrade. - expect(version).toBe(2) + expect(version).toBe(JOURNAL_DB_SCHEMA_VERSION) const table = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") .get('queued_messages') @@ -140,7 +144,9 @@ describe('draft rows', () => { { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } ) await first.close() - const db = new Database(journalDatabaseFile(root)) + // The table is created when the host opens its database, so the host restarts around the edit. + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) db.exec('DROP TABLE queued_messages') db.close() const journal = await open() @@ -252,7 +258,7 @@ describe('consume', () => { const cursor = journal.cursor() // Occupy the next sequence directly so the append's INSERT violates the // primary key inside the transaction, after the draft was transitioned. - const db = new Database(journalDatabaseFile(root)) + const db = new Database(journalDatabasePath(root)) db.prepare( 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' ).run(IDENTITY.sessionId, cursor.epoch, cursor.sequence + 1, tick(), '{}') @@ -528,7 +534,7 @@ describe('open-time repair and retention', () => { await journal.close() // Simulate the old build having written the rejection with no hook: put the // draft back to dispatched behind the stored fact. - const db = new Database(journalDatabaseFile(root)) + const db = new Database(journalDatabasePath(root)) db.prepare( "UPDATE queued_messages SET state = 'dispatched', returned_reason = NULL, returned_rejection = NULL WHERE message_id = ?" ).run('draft-1') @@ -546,7 +552,7 @@ describe('open-time repair and retention', () => { await consumeDraft(journal, 'draft-1') await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) await journal.close() - const db = new Database(journalDatabaseFile(root)) + const db = new Database(journalDatabasePath(root)) db.prepare( "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" ).run('draft-1') @@ -786,7 +792,7 @@ describe("the queue's Stop fact", () => { await queueDraft(journal, 'draft-legacy') await queueDraft(journal, 'draft-failed') await journal.queuedMessages.hold({ messageIds: ['draft-failed'], reason: 'send_failed' }) - const db = new Database(journalDatabaseFile(root)) + const db = new Database(journalDatabasePath(root)) db.prepare("UPDATE queued_messages SET hold_reason = 'stopped' WHERE message_id = ?").run( 'draft-legacy' ) diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts index f13d41373d73..7711b670438a 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-byte-accounting.test.ts @@ -8,7 +8,7 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { readAgentSessionHistory } from './agent-session-history-page' @@ -54,7 +54,7 @@ beforeEach(async () => { epochs = 0 journal = await journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => { epochs += 1 diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts index 5a9a6f20801c..8abfce258b85 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-forward-read-budget.test.ts @@ -8,15 +8,16 @@ import { type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { - insertJournalRow, - upsertJournalSessionRow -} from '../agent-session-journal/journal-row-table' import * as journalReducer from '../agent-session-journal/journal-reducer' import * as rowSchema from '../agent-session-journal/journal-row-schema' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + insertTestJournalRow, + publishTestJournalEpoch, + deleteTestJournalRow, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { readAgentSessionHistory } from './agent-session-history-page' @@ -40,42 +41,38 @@ afterEach(async () => { async function seedJournal(count: number) { root = await mkdtemp(join(tmpdir(), 'orca-history-read-budget-')) - const { db } = openJournalDatabase(journalDatabaseFile(root)) + const { db } = openTestJournalHostDatabase(root) const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1_000 } - try { - db.exec('BEGIN IMMEDIATE') - upsertJournalSessionRow(db, identity.sessionId, base.epoch, base.ts) - insertJournalRow(db, identity.sessionId, { + db.exec('BEGIN IMMEDIATE') + publishTestJournalEpoch(db, identity.sessionId, base.epoch) + insertTestJournalRow(db, identity.sessionId, { + ...base, + kind: 'epoch', + seq: 1, + reason: 'session_created', + providerHandle: identity.providerHandle + }) + for (let index = 0; index < count; index += 1) { + insertTestJournalRow(db, identity.sessionId, { ...base, - kind: 'epoch', - seq: 1, - reason: 'session_created', - providerHandle: identity.providerHandle + kind: 'item', + seq: index + 2, + itemId: `item-${index}`, + revision: 1, + body: { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: `${index}:${'x'.repeat(4096)}` }] + } }) - for (let index = 0; index < count; index += 1) { - insertJournalRow(db, identity.sessionId, { - ...base, - kind: 'item', - seq: index + 2, - itemId: `item-${index}`, - revision: 1, - body: { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: `${index}:${'x'.repeat(4096)}` }] - } - }) - } - db.exec('COMMIT') - } finally { - db.close() } - return journals.open({ identity, journalDir: root }) + db.exec('COMMIT') + return journals.open({ identity, stateDirectory: root }) } function observeForwardReads() { @@ -166,15 +163,8 @@ describe('forward history SQL read budget', () => { it('reports a sequence gap when the next page reaches it', async () => { const journal = await seedJournal(6) - const { db } = openJournalDatabase(journalDatabaseFile(root!)) - try { - db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( - identity.sessionId, - 4 - ) - } finally { - db.close() - } + const { db } = openTestJournalHostDatabase(root!) + deleteTestJournalRow(db, identity.sessionId, 4) const first = readAgentSessionHistory(journal, { sessionId: identity.sessionId, direction: 'after', @@ -199,16 +189,8 @@ describe('forward history SQL read budget', () => { 'preserves parse-stop behavior at lookahead: %s', async (rowJson) => { const journal = await seedJournal(6) - const { db } = openJournalDatabase(journalDatabaseFile(root!)) - try { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND seq = ?').run( - rowJson, - identity.sessionId, - 4 - ) - } finally { - db.close() - } + const { db } = openTestJournalHostDatabase(root!) + updateTestJournalRowJson(db, identity.sessionId, 4, rowJson) const page = readAgentSessionHistory(journal, { sessionId: identity.sessionId, direction: 'after', diff --git a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts index 97ce6ecd216d..ea6f44edb289 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-history-page.test.ts @@ -22,10 +22,11 @@ import { serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + insertTestJournalRow +} from '../agent-session-journal/journal-host-database-test-support' import type { JournalItemRow, JournalRow, @@ -77,7 +78,7 @@ beforeEach(async () => { epochs = 0 journal = await journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => { epochs += 1 @@ -512,17 +513,17 @@ async function reopenWithRawRows(rows: readonly RawSeedRow[]): Promise { diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index df2330346ae1..52fc3c86b9b6 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts @@ -4,7 +4,8 @@ import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journa // read — on both version axes, because only one of them is detectable before a // read. -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +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' @@ -12,20 +13,28 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase } from '../agent-session-journal/journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema' -import { loadJournal, replayJournal } from '../agent-session-journal/journal-open' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { readJournalEpochRows } from '../agent-session-journal/journal-row-table' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' +import { replayJournal } from '../agent-session-journal/journal-open' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal, + readTestJournalRows, + deleteTestJournalRow, + insertTestJournalRowJson, + updateTestJournalRowJson +} from '../agent-session-journal/journal-host-database-test-support' import type Database from '../../sqlite/sync-database' import { openAgentSessionJournalWithRecovery, - providerHistoryId, - recoveryJournalDir + providerHistoryId } from './agent-session-journal-recovery' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' +import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' -// Only the store's own replay goes through the mock; the probe's, inside the same module, does not. +// The store's replay goes through the mock: it is the only read of the journal an open makes. vi.mock('../agent-session-journal/journal-open', async (importOriginal) => { const actual = await importOriginal<{ replayJournal: typeof replayJournal }>() return { ...actual, replayJournal: vi.fn(actual.replayJournal) } @@ -76,7 +85,7 @@ function item(ordinal: number): AgentJournalItemIdentity { /** Fills a journal with `count` items and hands back its epoch. */ async function seedJournal(count: number): Promise { - const journal = await journals.open({ identity: IDENTITY, journalDir }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) for (let ordinal = 1; ordinal <= count; ordinal += 1) { await journal.appendItem( item(ordinal), @@ -92,7 +101,7 @@ async function seedJournal(count: number): Promise { /** A journal whose epoch row is gone: every surviving row is unanchored, so a * repair has to set aside the whole range. */ async function seedRepairableSession(): Promise { - const journal = await journals.open({ identity: IDENTITY, journalDir }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) await journal.appendSubmission({ clientMessageId: 'client-message-1', payloadFingerprint: 'fingerprint-1', @@ -113,18 +122,19 @@ async function withJournalDatabase( directory: string, run: (db: Database.Database) => void ): Promise { - const opened = openJournalDatabase(journalDatabaseFile(directory)) - try { - run(opened.db) - } finally { - opened.db.close() - } + run(openTestJournalHostDatabase(directory).db) +} + +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') } /** The same logical hole `findSequenceGap` detects at replay. */ async function deleteRow(seq: number): Promise { await withJournalDatabase(journalDir, (db) => { - db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(seq) + deleteTestJournalRow(db, CODEX_SESSION, seq) }) } @@ -159,7 +169,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = journals.track( await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }).then((result) => result.journal) @@ -167,19 +177,19 @@ describe('openAgentSessionJournalWithRecovery', () => { expect(opened.snapshot().items).toHaveLength(2) }) - it('reads the journal once: the probe is the open', async () => { + it("reads the journal once: the store's replay is the open", async () => { await seedJournal(2) vi.mocked(replayJournal).mockClear() const opened = journals.track( await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }).then((result) => result.journal) ) expect(opened.snapshot().items).toHaveLength(2) - expect(replayJournal).not.toHaveBeenCalled() + expect(replayJournal).toHaveBeenCalledOnce() }) it('rebuilds a holed journal in place on a fresh epoch', async () => { @@ -188,7 +198,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) @@ -203,67 +213,138 @@ describe('openAgentSessionJournalWithRecovery', () => { expect(texts.some((text) => text.includes('add a retry'))).toBe(true) }) - it('reconstructs a future row-body version into a sibling, never in place', async () => { + // A newer build's row is never rewritten under it, and there is no sibling to write instead: + // the chat reads what it can and refuses writes. + it('opens a future row-body version read-only and leaves it exactly as found', async () => { const epoch = await seedJournal(1) await withJournalDatabase(journalDir, (db) => { - db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ).run( + insertTestJournalRowJson( + db, CODEX_SESSION, - epoch, 3, - 1, JSON.stringify({ v: 99, seq: 3, epoch, kind: 'item', fence: 1, ts: 1 }) ) }) - const opened = journals.track( - await openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - journalDir, - fence: 1, - historyFilePath - }).then((result) => result.journal) - ) + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + database: openTestJournalHostDatabase(journalDir), + fence: 1, + historyFilePath + }) + journals.track(opened.journal) - // The unreadable journal is left exactly as found; a newer host still owns it. + expect(opened.recovery).toBeNull() + expect(opened.journal.isReadOnly).toBe(true) + // A send says to update, as a database a newer Orca wrote does. + const sent = await performSend( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read. + { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext, + { + clientMessageId: 'client-after-downgrade', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } + ) + expect(sent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } + }) await withJournalDatabase(journalDir, (db) => { - const rows = readJournalEpochRows(db, CODEX_SESSION, epoch) + const rows = readTestJournalRows(db, CODEX_SESSION, epoch) expect(rows.some((entry) => entry.rowJson.includes('"v":99'))).toBe(true) expect(rows).toHaveLength(3) }) - await opened.close() - await withJournalDatabase(recoveryJournalDir(journalDir), (db) => { - const sibling = db.prepare('SELECT row_json FROM journal_rows').all() - expect(JSON.stringify(sibling)).toContain('add a retry') - }) }) - it('reconstructs a future database version into a sibling, never in place', async () => { - await seedJournal(1) - await withJournalDatabase(journalDir, (db) => + // A downgrade: the database a newer Orca stamped opens read-only. The chat shows its history, a + // send says to update, and the file is left byte-identical. + it('shows a chat from a database a newer Orca stamped, and a send says to update', async () => { + await seedJournal(2) + await withJournalDatabase(journalDir, (db) => { db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - ) + }) + closeTestJournalHostDatabases() + const path = journalDatabasePath(journalDir) + const before = await digest(path) const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) journals.track(opened.journal) - expect(opened.recovery).toMatchObject({ - trigger: 'schema_unreadable', - reset: 'schema_unreadable' + + expect(opened.recovery).toBeNull() + expect(opened.journal.isReadOnly).toBe(true) + const texts = opened.journal.snapshot().items.map((entry) => JSON.stringify(entry.body)) + expect(texts.filter((text) => /item-[12]/.test(text))).toHaveLength(2) + const sent = await performSend( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a refused append returns before anything but the journal and fence is read. + { sessionId: CODEX_SESSION, journal: opened.journal, fence: 1 } as AgentSessionTurnContext, + { + clientMessageId: 'client-after-downgrade', + payloadFingerprint: 'fp', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + } + ) + expect(sent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } }) - expect(opened.recovery?.imported).toBeGreaterThan(0) - // No schema change, no row written, no row deleted. + await journals.closeAll() + expect(await digest(path)).toBe(before) + }) + + // T-logical-per-chat: one file holds every chat, and damage one chat's replay finds is still + // that chat's alone. Only it rebuilds; a neighbour's rows and epoch are not touched. + it('rebuilds only the damaged chat and leaves another chat in the same file untouched', async () => { + const neighbour: AgentSessionJournalIdentity = { + ...IDENTITY, + sessionId: 'neighbour-session', + providerHandle: { kind: 'codex', threadId: 'neighbour-thread' } + } + const other = await journals.open({ identity: neighbour, stateDirectory: journalDir }) + for (let ordinal = 1; ordinal <= 3; ordinal += 1) { + await other.appendItem( + item(ordinal), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `n-${ordinal}` }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + const neighbourEpoch = other.epoch + await other.close() + const database = openTestJournalHostDatabase(journalDir) + const neighbourBefore = readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch) + await seedJournal(3) await withJournalDatabase(journalDir, (db) => { - expect(db.pragma('user_version', { simple: true })).toBe(JOURNAL_DB_SCHEMA_VERSION + 1) - expect(db.prepare('SELECT count(*) AS total FROM journal_rows').get()).toMatchObject({ - total: 2 - }) + updateTestJournalRowJson(db, CODEX_SESSION, 3, '}{') }) + + const opened = await openAgentSessionJournalWithRecovery({ + identity: IDENTITY, + database, + fence: 1, + historyFilePath + }) + journals.track(opened.journal) + + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) + expect(opened.recovery?.imported).toBeGreaterThan(0) + const reopened = await journals.open({ identity: neighbour, stateDirectory: journalDir }) + expect(reopened.epoch).toBe(neighbourEpoch) + expect(readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch)).toEqual( + neighbourBefore + ) }) // The rehydrate deletes every live row to publish its replacement epoch, so @@ -271,7 +352,7 @@ describe('openAgentSessionJournalWithRecovery', () => { // Orca minted the submission, receipt and lifecycle identities; no provider // transcript can hand them back. it('rebuilds from provider history when the epoch row itself is gone', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: journalDir }) await journal.appendSubmission({ clientMessageId: 'client-message-1', payloadFingerprint: 'fingerprint-1', @@ -302,7 +383,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) @@ -320,7 +401,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: join(root, 'missing.jsonl') }) @@ -343,7 +424,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: empty }) @@ -355,19 +436,19 @@ describe('openAgentSessionJournalWithRecovery', () => { await first.journal.close() // The deletion is durable, so the demand for a rebuild has to be too. - expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) + expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) // A readable transcript rebuilds the epoch, and THAT is what retires it. const retried = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) journals.track(retried.journal) expect(retried.recovery?.imported).toBeGreaterThan(0) await retried.journal.close() - expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: false }) + expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: false }) }) // The reproduced path. Deleting sequence 1 leaves every surviving row @@ -381,7 +462,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: missing }) @@ -391,10 +472,10 @@ describe('openAgentSessionJournalWithRecovery', () => { await first.journal.close() // Reopen: the epoch still holds nothing but the repair, so recovery runs again. - expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) + expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) const reopened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: missing }) @@ -412,7 +493,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const epoch = reopened.journal.epoch await reopened.journal.close() await withJournalDatabase(journalDir, (db) => { - const rows = readJournalEpochRows(db, CODEX_SESSION, epoch) + const rows = readTestJournalRows(db, CODEX_SESSION, epoch) expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ kind: 'epoch', seq: 1 }) }) }) @@ -428,7 +509,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: empty }) @@ -441,7 +522,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const epoch = first.journal.epoch await first.journal.close() await withJournalDatabase(journalDir, (db) => { - const rows = readJournalEpochRows(db, CODEX_SESSION, epoch) + const rows = readTestJournalRows(db, CODEX_SESSION, epoch) expect(JSON.parse(rows[0]?.rowJson ?? '{}')).toMatchObject({ kind: 'epoch', seq: 1, @@ -450,12 +531,12 @@ describe('openAgentSessionJournalWithRecovery', () => { }) // The session still reports corrupt, so the next attach retries. - expect(await loadJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) + expect(await loadTestJournal(journalDir, CODEX_SESSION)).toMatchObject({ corrupt: true }) // And a transcript that DOES have content still rebuilds the timeline. const retried = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) @@ -471,7 +552,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: join(root, 'missing.jsonl') }) @@ -480,7 +561,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const retried = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) diff --git a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts index 29d324be9718..42da9a986dad 100644 --- a/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts +++ b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts @@ -1,28 +1,24 @@ -// Journal recovery: rehydrate the timeline from provider history. +// Journal recovery: rehydrate a chat's timeline from provider history. // -// Two triggers, and they need different destinations. A journal whose prefix is -// unusable is writable, so it is rebuilt in place on a fresh epoch. A journal -// written by a NEWER schema is not writable by this host at all — rebuilding it -// in place would fork the sequence space a newer host still owns — so the -// reconstruction goes to a schema-scoped sibling directory that is only ever -// written by hosts at this version and is never merged back. +// One trigger: a chat whose replayed prefix is unusable (a gap, an unanchored prefix, a malformed +// row). Its journal stays writable, so it is rebuilt in place on a fresh epoch, and only that chat +// is touched. Damage SQLite itself reports is not recovered here: the open fails, and the chat +// says it cannot be loaded. import type { AgentType } from '../../../shared/agent-status-types' -import { - AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - type AgentJournalResetReason, - type AgentSessionJournalIdentity, - type AgentSessionProviderHandle +import type { + AgentJournalResetReason, + AgentSessionJournalIdentity, + AgentSessionProviderHandle } from '../../../shared/agent-session-journal-types' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import' -import { loadJournal } from '../agent-session-journal/journal-open' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' export type AgentSessionJournalRecovery = { - trigger: 'journal_corrupt' | 'schema_unreadable' - /** What subscribers are told; both force a clean snapshot reload. */ + trigger: 'journal_corrupt' + /** What subscribers are told; it forces a clean snapshot reload. */ reset: AgentJournalResetReason epoch: string imported: number @@ -36,11 +32,6 @@ export type AgentSessionJournalOpened = { recovery: AgentSessionJournalRecovery | null } -/** Where a reconstruction lands when the real journal cannot be written. */ -export function recoveryJournalDir(journalDir: string): string { - return `${journalDir}-recovered-v${AGENT_SESSION_JOURNAL_SCHEMA_VERSION}` -} - /** The provider's own session id, which is what the transcript readers index * by — never the Orca session id. */ export function providerHistoryId(handle: AgentSessionProviderHandle): string { @@ -52,52 +43,24 @@ export function providerHistoryId(handle: AgentSessionProviderHandle): string { export async function openAgentSessionJournalWithRecovery(input: { identity: AgentSessionJournalIdentity - journalDir: string + database: JournalHostDatabase fence: number /** Resolve directly to a transcript instead of discovering it by session id. */ historyFilePath?: string | null + deferPerSessionImport?: boolean }): Promise { - const probe = loadJournal(input.journalDir, input.identity.sessionId) - if (probe?.readOnly) { - const journal = await openAgentSessionJournal({ - identity: input.identity, - journalDir: recoveryJournalDir(input.journalDir) - }) - return { journal, recovery: await rehydrateOrClose(input, journal, 'schema_unreadable') } - } const journal = await openAgentSessionJournal({ identity: input.identity, - journalDir: input.journalDir, - // The probe is this open's replay; omitted, not `null`, when there was nothing to load. - ...(probe ? { loaded: probe } : {}) + database: input.database, + deferPerSessionImport: input.deferPerSessionImport }) - if (!probe?.corrupt) { + if (!journal.needsRebuild) { return { journal, recovery: null } } - // `open()` drops the unusable suffix; a successful import rolls once more so - // the rebuilt timeline is the only content of its epoch. - return { journal, recovery: await rehydrateOrClose(input, journal, 'journal_corrupt') } -} - -/** `importLegacyTranscriptIntoJournal` can THROW rather than report `ok: false` - * — a journal write failure, for instance — and nothing else holds a reference - * to the journal this function just opened. A close that rejects is retryable, - * so the journal is retained rather than dropped with its handle still open. */ -async function rehydrateOrClose( - input: { - identity: AgentSessionJournalIdentity - fence: number - historyFilePath?: string | null - }, - journal: AgentSessionJournal, - trigger: AgentSessionJournalRecovery['trigger'] -): Promise { - try { - return await rehydrate({ ...input, journal, trigger }) - } catch (error) { - await agentSessionJournalCloseRetries.closeOrRetain(journal) - throw error - } + // `open()` dropped the unusable suffix; a successful import rolls once more so + // the rebuilt timeline is the only content of its epoch. A throw leaves nothing to release: + // the store holds no connection. + return { journal, recovery: await rehydrate({ ...input, journal, trigger: 'journal_corrupt' }) } } async function rehydrate(input: { @@ -107,8 +70,7 @@ async function rehydrate(input: { historyFilePath?: string | null trigger: AgentSessionJournalRecovery['trigger'] }): Promise { - const reset: AgentJournalResetReason = - input.trigger === 'schema_unreadable' ? 'schema_unreadable' : 'epoch_changed' + const reset: AgentJournalResetReason = 'epoch_changed' const result = await importLegacyTranscriptIntoJournal({ journal: input.journal, agent: input.identity.agent satisfies AgentType, 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 98a6f7e42dc4..0064f36ce584 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,7 +20,6 @@ import { DISPATCH_REJECTED_CANCELLED } from '../../../shared/structured-agent-session-dispatch-rejection' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { @@ -44,6 +43,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -86,7 +86,7 @@ async function startHost(): Promise { setOption: vi.fn(async () => undefined), ...adapterExtras }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: 5, idleMs }, @@ -238,10 +238,7 @@ async function writeAsEarlierProcess( }) const journal = await openAgentSessionJournal({ identity: journalIdentityFor(record, params), - journalDir: journalDirectoryFor(root, { - workspaceId: record.location.workspaceId, - sessionId: SESSION - }) + database: openTestJournalHostDatabase(root) }) await write(journal, record.lease.runtimeFence) await journal.close() 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 7c12909e0363..4e5d44795a31 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 @@ -18,6 +18,7 @@ import { import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' import type { AgentSessionCreatePhaseRecorder } from '../../observability/agent-session-instrumentation' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'legacy-session' @@ -154,8 +155,7 @@ describe('structured session acquisition options', () => { const first = await performAttach({ store: initialStore, adapter: withHistory('created'), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -195,8 +195,7 @@ describe('structured session acquisition options', () => { const second = await performAttach({ store, adapter: withHistory('resumed'), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -230,8 +229,7 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: sessionAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -263,8 +261,7 @@ describe('structured session acquisition options', () => { performAttach({ store, adapter: sessionAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken, claimKeyId: 'key-1', @@ -295,8 +292,7 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: adapter({ origin: 'created' }), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -335,8 +331,7 @@ describe('structured session acquisition options', () => { models: [] } }), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -375,8 +370,7 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: sessionAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -415,8 +409,7 @@ describe('structured session acquisition options', () => { performAttach({ store, adapter: failingAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -510,8 +503,10 @@ describe('structured session acquisition options', () => { performAttach({ store: target, adapter: failingAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!, failingAdapter), + openConversation: openTestAttachConversation( + openTestJournalHostDatabase(root!), + failingAdapter + ), authority: { spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', claimKeyId: 'key-1', @@ -612,8 +607,7 @@ describe('the tab a create reserves', () => { return performAttach({ store, adapter: adapter({ origin: 'created' }), - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', 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 371b942c2f41..ac5f1d1b088c 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 @@ -15,9 +15,9 @@ import { import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow' import { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import * as legacyImport from '../agent-session-journal/journal-legacy-import' import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'codex_adopting_session' @@ -123,8 +123,7 @@ async function attach( return performAttach({ store, adapter: sessionAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -221,13 +220,10 @@ describe('adopting a provider conversation on create', () => { vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce( new Error('disk write failed') ) - const close = vi.spyOn(agentSessionJournalCloseRetries, 'closeOrRetain') const sessionAdapter = adapter() await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed') expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1) expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1) - // The journal is the conversation's, not the attach's: a failed import closes nothing. - expect(close).not.toHaveBeenCalled() }) it('leaves the conversation writable when the import fails after acquiring', async () => { @@ -238,7 +234,7 @@ describe('adopting a provider conversation on create', () => { const host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 dbb3a63e7b54..19280db41e50 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 @@ -24,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -136,7 +137,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index 8fdee5c62bdb..c469035c4689 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -48,7 +48,6 @@ import type { AgentSessionJournal } from '../agent-session-journal/journal-store export type AttachFlowInput = { store: AgentSessionRecordStore adapter: StructuredAgentSessionAdapter - journalRoot: string authority: AgentSessionAttachAuthority callerKey: string params: AgentSessionAttachParams @@ -227,7 +226,6 @@ export async function performAttach( attached = await attachJournal({ record, params, - journalRoot: input.journalRoot, adapter: input.adapter, openConversation: input.openConversation, providerHistoryWindow diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts index 6fc715022aa6..9cfdf6a9b87c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-orchestration.ts @@ -135,7 +135,6 @@ async function runAttach( const attached = await performAttach({ store: context.deps.store, adapter: context.deps.adapter, - journalRoot: context.deps.journalRoot, eventSink: attemptSink.sink, // The superseded child's writes settle into its own journal before a new child starts. onAcquiring: async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts index 36f6ebc79e85..e22c5dea9fdc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-reconciliation.test.ts @@ -10,9 +10,11 @@ import { agentSessionRecordFixture } from '../../../shared/agent-session-record. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' import { digestPayload } from '../agent-session-journal/journal-payload-bounds' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { ProviderHistoryWindow } from '../agent-session-journal/journal-submission-reconciler' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { @@ -68,10 +70,7 @@ function adapterWith(providerHistoryWindow?: () => Promise { it('leaves a message the open conversation still has queued alone (W4′e)', async () => { const journal = await journals.open({ identity: IDENTITY, - journalDir: journalDirectoryFor(root, { - workspaceId: IDENTITY.workspaceId, - sessionId: IDENTITY.sessionId - }) + stateDirectory: root }) await journal.appendSubmission({ clientMessageId: 'queued', @@ -185,8 +180,6 @@ describe('attachJournal restart reconciliation', () => { const attached = await attachJournal({ record: RECORD, params: PARAMS, - journalRoot: root, - adapter, openConversation: async () => journal }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts index 1e2322cbc6d8..aa0ae9e29e87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-test-conversation.ts @@ -1,4 +1,5 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' @@ -6,12 +7,12 @@ import { openStructuredAgentSessionConversationJournal } from './structured-agen /** For a test that attaches without a host: the conversation's journal, through the one open a * host would take, so the attach under test adopts it the way it adopts the host's. */ export function openTestAttachConversation( - journalRoot: string, + journalDatabase: JournalHostDatabase, adapter: Pick = {} ): (record: AgentSessionRecord) => Promise { return async (record) => ( - await openStructuredAgentSessionConversationJournal({ journalRoot, adapter }, record, { + await openStructuredAgentSessionConversationJournal({ journalDatabase, adapter }, record, { acquisition: true }) ).session.journal diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 10c4866cea3f..85f99056c3a7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -184,7 +184,6 @@ export type AttachedJournal = { export async function attachJournal(input: { record: AgentSessionRecord params: AgentSessionAttachParams - journalRoot: string adapter: StructuredAgentSessionAdapter /** The host's open conversation, whose journal the attach adopts. */ openConversation: (record: AgentSessionRecord) => Promise 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 61e3f7ef3ad6..0e34cca99a71 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 @@ -26,6 +26,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -71,7 +72,7 @@ beforeEach(async () => { store, // The production router is what declares create support; the bare adapter only knows locations. adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 0fe206201604..103df14eb733 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 @@ -25,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -66,7 +67,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 5b56db5552e6..6787a4e7586e 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 @@ -14,6 +14,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-claude' } const CLAUDE_SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' @@ -83,7 +84,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-claude', now: () => NOW 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 e8b8d0f00442..ec9bc17e6964 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 @@ -17,6 +17,7 @@ import { } from '../../claude/claude-structured-session-test-support' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' 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' import { HOST_TEST_NOW as NOW, @@ -81,7 +82,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 4a8f0a1440f9..d201360cd542 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 @@ -9,7 +9,10 @@ import { identityFor } from '../../claude/claude-structured-session-test-support' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { stopStructuredAgentSessionAgentUnderSerialize } from './structured-agent-session-host-lifetime' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' @@ -75,7 +78,7 @@ describe('Claude root-exit stop', () => { }) const journal = await journals.open({ identity: { ...identityFor(), hostId: 'local', workspaceId: 'folder-1' }, - journalDir: join(root, 'journal') + stateDirectory: join(root, 'journal') }) const close = vi.spyOn(journal, 'close') const publishStatus = vi.fn() @@ -112,7 +115,12 @@ describe('Claude root-exit stop', () => { } ] ]) - const deps = { store, adapter, journalRoot: root, claimKeyId: 'key-1' } + const deps = { + store, + adapter, + journalDatabase: openTestJournalHostDatabase(root), + claimKeyId: 'key-1' + } const runtimeState = new StructuredAgentSessionHostRuntimeState(deps) claude.connections[0]!.handlers.onExit?.(new Error('provider exited')) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts deleted file mode 100644 index 74d5355edd5a..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// A close that REJECTED did not release the handle. -// -// `AgentSessionJournal.close()` is retryable by design: the release step is -// unguarded precisely so a second call is a second attempt. Callers that did -// `close().catch(() => undefined)` and then threw or overwrote their map entry -// turned that retryable failure into a permanent orphan — on POSIX a silent -// leak, on Windows a handle that blocks renaming or removing the directory. -// -// These drive the REAL callers: the attach orchestration's `onAttached`, and -// host teardown, which is what runtime stop calls. Only the lease/record -// machinery around them is stubbed. - -import { access, mkdtemp, rename, 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 type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { - agentSessionJournalCloseRetries, - JournalCloseRetryRegistry -} from '../agent-session-journal/journal-close-retry' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' -import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' -import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' - -const attachFlow = vi.hoisted(() => ({ - journal: null as AgentSessionJournal | null -})) - -// The lease reservation, the record store and the provider child are not what -// these cases are about; `onAttached` is, and it is the real one. -vi.mock('./structured-agent-session-attach-flow', () => ({ - performAttach: async (input: { - onAttached: ( - attached: { journal: AgentSessionJournal; recovery: null }, - generation: string | null - ) => Promise - }) => { - await input.onAttached({ journal: attachFlow.journal!, recovery: null }, null) - return { ok: true, value: {} } - } -})) - -const SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: SESSION, - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: SESSION } -} - -let root: string -const journals = createTrackedJournalOpener() - -async function exists(path: string): Promise { - return access(path) - .then(() => true) - .catch(() => false) -} - -async function expectNothingHoldsTheDirectory(directory: string): Promise { - const dbPath = journalDatabaseFile(directory) - expect(await exists(`${dbPath}-wal`)).toBe(false) - expect(await exists(`${dbPath}-shm`)).toBe(false) - // The half that actually fails on Windows when a handle is still open. - const moved = `${directory}-moved` - await rename(directory, moved) - await rm(moved, { recursive: true }) -} - -function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSession { - return { - journal, - params: {} as StructuredAgentSessionHostSession['params'], - child: null - } -} - -/** A journal whose close rejects until `failures` is exhausted, wrapping a real - * store so the handle it holds is a real one. */ -function flakyClose(journal: AgentSessionJournal, failures: number): AgentSessionJournal { - let remaining = failures - return new Proxy(journal, { - get(target, property, receiver) { - if (property !== 'close') { - // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. - return Reflect.get(target, property, receiver) - } - return async () => { - if (remaining > 0) { - remaining -= 1 - throw new Error('close rejected') - } - await target.close() - } - } - }) -} - -function attachContext( - sessions: Map -): StructuredAgentSessionAttachContext { - const eventSink = { - sink: {}, - drained: async () => ({ ok: true }) as const, - unbind: () => undefined, - bind: () => undefined, - close: () => undefined - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the attach reads only the members defined here. - return { - deps: { store: { getRecord: () => null }, claimKeyId: 'key-1', journalRoot: root }, - runtimeState: { - resolveRecovery: async () => undefined, - eventSinkFor: () => eventSink, - currentEventSink: () => eventSink, - mintEventSink: () => eventSink, - adoptEventSink: () => undefined, - probeOwner: async () => ({ outcome: 'pid-absent' }), - discardEventSink: () => undefined - }, - sessions, - subscribers: { - reset: () => undefined, - snapshot: () => undefined, - publish: () => undefined - }, - tasks: { trackAttach: (task: Promise) => task }, - reconcileLeases: async () => null, - serialize: (_sessionId: string, task: () => Promise) => task(), - now: () => 1, - publishStatus: () => undefined - } as unknown as StructuredAgentSessionAttachContext -} - -const attachParams = { - envelope: { sessionId: SESSION, clientOperationId: 'op-1' } -} as unknown as Parameters[2] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-close-retry-')) - // The registry is process-wide; drain it so one case cannot see another's. - await agentSessionJournalCloseRetries.retryAll() -}) - -afterEach(async () => { - await agentSessionJournalCloseRetries.retryAll() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('the registry', () => { - it('retains a journal whose close rejected and releases it on the retry', async () => { - const directory = join(root, 'retained') - const registry = new JournalCloseRetryRegistry() - const journal = flakyClose( - await journals.open({ identity: IDENTITY, journalDir: directory }), - 1 - ) - - const first = await registry.closeOrRetain(journal) - expect(first.closed).toBe(false) - expect(registry.pendingDirectories).toEqual([directory]) - - expect(await registry.retryAll()).toEqual([]) - expect(registry.pendingDirectories).toEqual([]) - await expectNothingHoldsTheDirectory(directory) - }) -}) - -describe('the attach orchestration', () => { - // The attach adopts the conversation's one open journal; it never opens a second handle, so - // there is no replacement to abort and no provisional journal to close. - it('keeps the journal it adopted indexed and open when an attach succeeds', async () => { - const directory = join(root, 'adopted') - const journal = await journals.open({ identity: IDENTITY, journalDir: directory }) - attachFlow.journal = journal - const sessions = new Map([[SESSION, hostSession(journal)]]) - - await attachStructuredAgentSession(attachContext(sessions), 'caller-1', attachParams) - - expect(sessions.get(SESSION)?.journal).toBe(journal) - await expect( - journal.appendItem( - { provider: 'orca', clientMessageId: 'after-attach' }, - { - kind: 'status', - text: 'still writable' - }, - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - ).resolves.toBeDefined() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - }) - - it('leaves the conversation indexed and open when the sink barrier fails', async () => { - const directory = join(root, 'adopted-barrier') - const journal = await journals.open({ identity: IDENTITY, journalDir: directory }) - attachFlow.journal = journal - const sessions = new Map([[SESSION, hostSession(journal)]]) - const context = attachContext(sessions) - const failing = { - sink: {}, - drained: async () => ({ ok: false, error: new Error('sink barrier failed') }) as const, - unbind: () => undefined, - bind: () => undefined, - close: () => undefined - } - // The re-attach binds the sink the live child already writes through. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial sink double; the attach reads only drained/bind/close from it. - context.runtimeState.currentEventSink = (() => - failing) as unknown as typeof context.runtimeState.currentEventSink - - await expect(attachStructuredAgentSession(context, 'caller-1', attachParams)).rejects.toThrow( - 'sink barrier failed' - ) - - // A failed attach does not end the conversation: its queued messages and the failure row - // are written into this same journal. - expect(sessions.get(SESSION)?.journal).toBe(journal) - await expect( - journal.appendItem( - { provider: 'orca', clientMessageId: 'after-failure' }, - { - kind: 'status', - text: 'still writable' - }, - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - ).resolves.toBeDefined() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - }) -}) - -describe('teardown, which is what runtime stop calls', () => { - it('retries the journals earlier failure paths could not close', async () => { - const orphanDir = join(root, 'orphan') - const orphan = flakyClose(await journals.open({ identity: IDENTITY, journalDir: orphanDir }), 1) - expect((await agentSessionJournalCloseRetries.closeOrRetain(orphan)).closed).toBe(false) - - // The first teardown reports the still-failing close instead of hiding it. - await tearDownStructuredAgentSessionHost({ phases: [], sessions: new Map() }) - - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - await expectNothingHoldsTheDirectory(orphanDir) - }) - - it('surfaces a retained close that still rejects, and keeps it for the next stop', async () => { - const orphanDir = join(root, 'stubborn') - const orphan = flakyClose(await journals.open({ identity: IDENTITY, journalDir: orphanDir }), 2) - await agentSessionJournalCloseRetries.closeOrRetain(orphan) - - await expect( - tearDownStructuredAgentSessionHost({ phases: [], sessions: new Map() }) - ).rejects.toMatchObject({ errors: [expect.objectContaining({ message: 'close rejected' })] }) - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([orphanDir]) - - // A later stop is a real retry, not a no-op. - await tearDownStructuredAgentSessionHost({ phases: [], sessions: new Map() }) - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - await expectNothingHoldsTheDirectory(orphanDir) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts index b9b7021e854d..a001eddb3f82 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-publication.test.ts @@ -11,7 +11,7 @@ import { EMPTY_STRUCTURED_AGENT_SESSION, reduceStructuredAgentSession } from '../../../shared/structured-agent-session-reducer' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' import { adapterFor, @@ -60,7 +60,7 @@ it('delivers catalog changes through existing frames without resending them on o state = reduceStructuredAgentSession(state, { type: 'event', event }) }) try { - const journal = await journals.open({ identity: identityFor(), journalDir: root }) + const journal = await journals.open({ identity: identityFor(), stateDirectory: root }) const sessionId = identityFor().sessionId let commands: AgentSessionSlashCommand[] | undefined = [ { name: 'loaded', kind: 'command', kindUnspecified: true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts index 0fbd1a0a233c..f69129183b4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-lifetime.ts @@ -41,6 +41,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { const { sessions, serialize } = host const deps = () => host.context().deps const readRefusals = createJournalOpenReadRefusals() + // The owed copy fails as an open does: the reader gets the classified refusal, never its text. + const whenImported = (sessionId: string, session: StructuredAgentSessionHostSession) => + session.journal.whenImported().catch((error: unknown) => { + throw readRefusals.refusal(sessionId, error) + }) // The sweep's stop puts an idle agent to rest: nothing is queued, so no loop reads its cause. const stopAgent = (sessionId: string) => stopStructuredAgentSessionAgentUnderSerialize(host.context(), sessionId) @@ -98,6 +103,11 @@ export function createStructuredAgentSessionConversationLifetime(host: { conversation: async (sessionId: string): Promise => { const open = sessions.get(sessionId) if (open) { + // Restore left its per-chat file uncopied; a reader gets the chat from the one database. + // Awaited only then: an open conversation otherwise answers in the same turn. + if (open.journal.importPending) { + await whenImported(sessionId, open) + } readRefusals.forget(sessionId) return open } @@ -125,6 +135,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { reason: 'recordMissing' }) } + // Restore may have opened it while this waited. + if (session.journal.importPending) { + await whenImported(sessionId, session) + } readRefusals.forget(sessionId) return session }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index dd8853954e3f..ec938f17f13b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts @@ -9,7 +9,7 @@ // wakes. Nothing here starts a provider child. import type { AgentJournalResetReason } from '../../../shared/agent-session-journal-types' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' @@ -37,13 +37,17 @@ export type OpenedStructuredAgentSessionConversation = { export type StructuredAgentSessionConversationOpenDeps = { store: Pick adapter: Pick - journalRoot: string + journalDatabase: JournalHostDatabase onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] } /** An acquisition's own open: its reserve cleared the record's death evidence, so it settles * what the gone generation left running itself, from what it read before. */ -export type StructuredAgentSessionConversationOpenOptions = { acquisition?: boolean } +export type StructuredAgentSessionConversationOpenOptions = { + acquisition?: boolean + /** A restore's open, which copies no per-chat file: see `AgentSessionJournal.whenImported`. */ + deferPerSessionImport?: boolean +} export type StructuredAgentSessionConversationOpenContext = { deps: StructuredAgentSessionConversationOpenDeps @@ -90,12 +94,10 @@ export async function openStructuredAgentSessionConversationJournal( const identity = journalIdentityFor(record, params) const opened = await openAgentSessionJournalWithRecovery({ identity, - journalDir: journalDirectoryFor(deps.journalRoot, { - workspaceId: record.location.workspaceId, - sessionId - }), + database: deps.journalDatabase, fence, - historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null + historyFilePath: (await deps.adapter.historyFilePath?.({ identity })) ?? null, + deferPerSessionImport: options.deferPerSessionImport }) try { // A queued row found here is a leftover the delivery loop's first step rejects; a handed-over 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 ab0368a6b921..2079b25c1bb0 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 @@ -22,6 +22,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const ALREADY_FINISHED = 'The provider had already finished this turn.' @@ -67,7 +68,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts index 954525c64299..3557594b7513 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-conversations.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { hostTestAttachParams } from './structured-agent-session-host-test-data' @@ -31,7 +31,7 @@ afterEach(async () => { async function openJournal(name: string): Promise { root ??= await mkdtemp(join(tmpdir(), 'orca-conversations-')) - return journals.open({ identity: IDENTITY, journalDir: join(root, name) }) + return journals.open({ identity: IDENTITY, stateDirectory: join(root, name) }) } function session(journal: AgentSessionJournal) { 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 6e364ccb477b..a4831b1a7b14 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 @@ -23,6 +23,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const CHILD_PID = 4321 @@ -95,7 +96,7 @@ function host( return new StructuredAgentSessionHost({ store, adapter, - journalRoot: generationRoot(generation), + journalDatabase: openTestJournalHostDatabase(generationRoot(generation)), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW, 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 f91c8baba15d..ac1218fb2a78 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 @@ -20,7 +20,10 @@ import { } 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 { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { AgentSessionAcquisitionExitUnprovenError, @@ -122,10 +125,7 @@ async function seedClaudeToolTurn(): Promise { agent: 'claude', providerHandle: { kind: 'claude', sessionId: PROVIDER_SESSION, leafUuid: null } }, - journalDir: journalDirectoryFor(root, { - workspaceId: LOCATION.workspaceId, - sessionId: SESSION - }), + database: openTestJournalHostDatabase(root), now: () => now }) await journal.appendSubmission({ @@ -170,7 +170,7 @@ function openHost(overrides: Partial): void { setOption: vi.fn(), supportsCreate: () => true }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', now: () => RELAUNCHED_AT, @@ -193,6 +193,7 @@ beforeEach(async () => { afterEach(async () => { await host?.flushAllStreamedEvents() + closeTestJournalHostDatabases() await rm(root, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 5a55bbb26cc0..6677a50b4470 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts @@ -16,6 +16,7 @@ import { settleStructuredAgentSessionDeadGeneration, unfinishedStructuredAgentSessionWorkWasInterrupted } from './structured-agent-session-dead-generation-settlement' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'The agent stopped while this response was in progress. You can continue in this conversation.' @@ -35,7 +36,7 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_000 }) }) @@ -404,7 +405,7 @@ describe('dead structured-session generation settlement', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_000 }) expect(settledProducers()).toEqual(settled) 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 317e7ca81a0d..3547f8bdd776 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 @@ -21,6 +21,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): stderr tail' @@ -58,7 +59,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 69f9b95c03a3..435a9be82b16 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 @@ -19,6 +19,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'claude stream-json exited (code 1): claude: not signed in' @@ -54,7 +55,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts index 8ce8d7d1507e..fdaa5b72d506 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-forget-status.test.ts @@ -16,7 +16,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' import { AgentHookServer } from '../../agent-hooks/server' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration' import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' @@ -107,7 +110,7 @@ async function workingSession(): Promise<{ journal: AgentSessionJournal records: Map }> { - const journal = await journals.open({ identity: IDENTITY, journalDir: join(root, SESSION) }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: join(root, SESSION) }) await journal.appendItem( PROMPT, { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'ship it' }] }, @@ -171,7 +174,11 @@ function attachContext( } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a partial context double; the attach reads only the members defined here. return { - deps: { store: { getRecord: () => null }, claimKeyId: 'key-1', journalRoot: root }, + deps: { + store: { getRecord: () => null }, + claimKeyId: 'key-1', + journalDatabase: openTestJournalHostDatabase(root) + }, runtimeState: { resolveRecovery: async () => undefined, eventSinkFor: () => eventSink, 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 72317e8ed814..df6ee094967e 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 @@ -22,6 +22,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -133,7 +134,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts index c9ba8714b4ee..f2140c98fbc7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-lifetime.ts @@ -2,8 +2,8 @@ // // Two operations, because they end two different things. Stopping the agent ends the provider // child and hands the lease back; the conversation — its open journal, its status row and its -// readers — stays, and the next send starts a new child. Closing the conversation drops the open -// journal handle, a cache the next read or write reopens. +// readers — stays, and the next send starts a new child. Closing the conversation drops its +// in-memory fold, a cache the next read or write rebuilds from the host's journal database. // // Both are written for a caller already inside the session's serialize: the queue is not // reentrant, so every public entry point takes it once and calls these. @@ -183,10 +183,10 @@ export function structuredAgentSessionConversationClosable( } /** - * Drops the conversation's open handle. The entry leaves the map BEFORE the handle closes, so a - * lock-free reader sees an open handle or none — never one that is closing — and one arriving - * after the delete waits behind this step and reopens. Answers false, closing nothing, when the - * handle is still more than a cache. + * Drops the conversation's open fold: a map delete, then its admitted writes drain. The entry + * leaves the map first, so a lock-free reader sees an open conversation or none — never one that + * is closing — and one arriving after the delete waits behind this step and reopens. Answers + * false, closing nothing, when the conversation is still more than a cache. */ export async function closeStructuredAgentSessionConversationUnderSerialize( context: Pick & { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts index 8d54ef52784a..5f336345b5e6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-runtime-state.test.ts @@ -1,14 +1,28 @@ -import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_RECORD_SCHEMA_VERSION, type AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionHostRuntimeState } from './structured-agent-session-host-runtime-state' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host' const NOW = 1_800_000_000_000 +// The runtime state never reads the journal database; it only fills the deps' shape. +const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-host-runtime-state-')) +afterAll(() => { + closeTestJournalHostDatabases() + rmSync(stateDirectory, { recursive: true, force: true }) +}) + function reservedRecord(): AgentSessionRecord { return { schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION, @@ -48,10 +62,11 @@ function runtimeState( record: AgentSessionRecord | null, probeOwner: NonNullable ) { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime state reads only store, probeOwner and optional deps; the rest of the host's deps are unused here. const deps = { store: { getRecord: () => record } as unknown as AgentSessionRecordStore, adapter: {}, - journalRoot: '/tmp', + journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', probeOwner } as StructuredAgentSessionHostDeps @@ -119,13 +134,14 @@ describe('host runtime-state owner probe', () => { throw new Error('lease probe unavailable') }) const record = liveRecord() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the lease renewal under test reads only listRecords and getRecord from the store. const deps = { store: { listRecords: () => [record], getRecord: () => record }, adapter: {}, - journalRoot: '/tmp', + journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', probeOwner, onEventSinkError diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index bc9a48c5dacb..4ce0b3885adf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts @@ -1,10 +1,9 @@ // Host teardown, made failure-complete. // -// A trailing "close every journal" statement is skipped on exactly the path -// that leaks: `flushAllEventSinks` throws BY DESIGN when a sink barrier fails, -// and the attach drain can reject too. Every connection would then be left open -// with the global runtime reference already cleared — the one state from which -// nothing can ever close them. +// Every phase runs whatever an earlier one threw: `flushAllEventSinks` throws BY DESIGN when a +// sink barrier fails, and the attach drain can reject too. Each conversation is then retired — +// what it still queues settled, its admitted writes drained — before the runtime closes the one +// journal connection, last. import type { AgentSessionResumeTrigger } from '../../../shared/agent-session-resume-marker' import { SUPERVISED_GRACEFUL_EXIT_MS } from '../../claude/claude-child-exit-proof-ladder' @@ -17,7 +16,6 @@ import { type StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' import { withTimeout } from '../../../shared/promise-timeout-fallback' -import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' export type StructuredAgentSessionTeardownPhase = { @@ -100,7 +98,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { ] } -export async function tearDownStructuredAgentSessionHost(input: { +async function tearDownStructuredAgentSessionHost(input: { phases: readonly StructuredAgentSessionTeardownPhase[] sessions: Map retainSessionIds?: ReadonlySet @@ -120,7 +118,7 @@ export async function tearDownStructuredAgentSessionHost(input: { const entries = [...input.sessions.entries()].filter( ([sessionId]) => !input.retainSessionIds?.has(sessionId) ) - // `allSettled`, so one rejected close cannot skip the others. + // `allSettled`, so one failed settlement cannot skip the others. const closed = await Promise.allSettled( entries.map(async ([sessionId, session]) => { await input.abandonQueued?.(sessionId, session) @@ -130,8 +128,8 @@ export async function tearDownStructuredAgentSessionHost(input: { closed.forEach((result, index) => { const sessionId = entries[index]?.[0] if (result.status === 'fulfilled') { - // Only a FULFILLED close drops the entry. One that rejected stays indexed, - // which is what makes a later close a real retry rather than a no-op. + // Only a settled conversation drops out. One whose queued sends could not be settled stays + // indexed, so a later stop retries that settlement. if (sessionId !== undefined) { input.sessions.delete(sessionId) input.acknowledgeSessionRelease?.(sessionId) @@ -141,10 +139,6 @@ export async function tearDownStructuredAgentSessionHost(input: { failures.push(result.reason) }) - // Journals an earlier failure path could not close are retried HERE, which is - // the only place that owns them once their caller has unwound. - failures.push(...(await agentSessionJournalCloseRetries.retryAll())) - if (failures.length > 0) { throw new AggregateError(failures, 'agent session host teardown failed') } 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 b069fecd1bb1..19f2dd003a4d 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 @@ -18,6 +18,7 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -74,7 +75,7 @@ describe('abandoning a structured agent-session host', () => { outcome: 'identity-matched', matchedOn: ['process-start-time'] }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 d354ad911f06..a0caaa45b936 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 @@ -9,7 +9,10 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- 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 { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionDispatchOutcome, StructuredAgentSessionAdapter @@ -148,7 +151,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), recoveryCapsule: new AgentSessionRecoveryCapsule(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -157,8 +160,8 @@ beforeEach(async () => { }) afterEach(async () => { - await journals.closeAll() await host.flushAllStreamedEvents() + await journals.closeAll() await rm(root, { recursive: true, force: true }) }) 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 44699536a6fe..80ea85e86835 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 @@ -6,6 +6,7 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wi import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import type { AgentSessionSpawnTokenScan } from '../../runtime/agent-session-spawn-token-process-scan' +import type { JournalHostDatabase } from '../agent-session-journal/journal-host-database' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter, @@ -95,7 +96,8 @@ export type StructuredAgentSessionHostDeps = { adapter: StructuredAgentSessionAdapter /** Optional advisory recovery storage, independent of conversation backups. */ recoveryCapsule?: AgentSessionRecoveryCapsule - journalRoot: string + /** The host's one chat journal database. */ + journalDatabase: JournalHostDatabase claimKeyId: string probeOwner?: (record: AgentSessionRecord) => Promise probeOwners?: ( 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 5db9e5d0d742..590f69c79840 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 @@ -27,6 +27,7 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' let root: string let store: AgentSessionRecordStore @@ -133,7 +134,7 @@ describe('attach', () => { host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), acquire }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -563,7 +564,7 @@ describe('restart', () => { host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), ...adapterOverrides }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-b', probeOwner, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts index 2394b604171c..1fc223808f6b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-idle-sweep.ts @@ -3,7 +3,7 @@ // Nothing a viewer does keeps an agent alive or starts one: a chat on screen and a chat in a // background tab are the same to this sweep. Every few minutes it looks at each open conversation // and stops the provider child of one that has been quiet for the idle window and owes no work, -// then drops the open journal handle of one that is only a cache. The conversation itself — its +// then drops the in-memory fold of one that is only a cache. The conversation itself — its // record, tab, status row and readers — is untouched, and the next send starts a new child. // // Owed work is derived on every tick, never stored, so there is nothing to disagree with it. 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 new file mode 100644 index 000000000000..fca1d49c06cf --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts @@ -0,0 +1,138 @@ +// A chat whose per-chat file did not read back as its copy is refused as history that cannot be +// loaded: on a send and a Stop as on a read. Each retry runs the same copy, so "try again" is wrong. + +import { cp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + JournalImportMismatchError, + journalOpenRefusalError +} from '../agent-session-journal/journal-open-failure' +import { replayJournal } from '../agent-session-journal/journal-open' +import type * as PerSessionImport from '../agent-session-journal/journal-per-session-import' +import { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + adapter, + attach, + CALLER, + envelope, + hostTestState, + replaceHostTestState +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const { copy } = vi.hoisted(() => ({ copy: { mismatched: false } })) + +vi.mock('../agent-session-journal/journal-per-session-import', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + // What the copy's verify throws for a file that does not read back as copied. + importPerSessionJournal: async ( + input: Parameters[0] + ) => { + if (!copy.mismatched) { + return actual.importPerSessionJournal(input) + } + throw journalOpenRefusalError( + new JournalImportMismatchError( + `per-chat journal of ${input.identity.sessionId} read back short` + ) + ) + }, + // Restore lists a chat still in its per-chat file from a fold of it, and owes the copy. + previewPerSessionJournal: async ( + input: Parameters[0] + ) => + copy.mismatched + ? replayJournal(input.database.db, input.identity.sessionId) + : actual.previewPerSessionJournal(input) + } +}) + +const UNLOADABLE = { + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Unable to load this chat.', + details: { reason: 'journalCorrupt' } + } +} + +const relaunchedRoots: string[] = [] + +beforeEach(() => { + copy.mismatched = false + vi.spyOn(console, 'warn').mockImplementation(() => undefined) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + relaunchedRoots.splice(0).map((dir) => rm(dir, { recursive: true, force: true })) + ) +}) + +/** A restarted process over the chat's files, holding no chat open; its copy now mismatches. */ +async function relaunchWithMismatchedCopy(): Promise { + const before = hostTestState() + await attach() + await before.host.flushStreamedEvents(SESSION) + await before.store.renewLeases([]) + const relaunched = `${before.root}-relaunched` + relaunchedRoots.push(relaunched) + // A dead process holds no lock. + await cp(before.root, relaunched, { + recursive: true, + filter: (source) => !source.includes('.lock') + }) + const store = await AgentSessionRecordStore.open({ + directory: join(relaunched, 'store'), + hostId: 'local' + }) + const host = new StructuredAgentSessionHost({ + store, + adapter: adapter(), + journalDatabase: openTestJournalHostDatabase(relaunched), + claimKeyId: 'key-1', + mintSpawnToken: () => 'spawn-next', + now: () => NOW + }) + replaceHostTestState({ store, host }) + copy.mismatched = true + return host +} + +function send(host: StructuredAgentSessionHost) { + const body = hostTestMessage('sent to a chat that did not copy') + return host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) +} + +it('refuses a send and a Stop that open the chat as unloadable', async () => { + const host = await relaunchWithMismatchedCopy() + + await expect(send(host)).resolves.toMatchObject(UNLOADABLE) + await expect( + host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', { turnId: 'turn-1' }), + turnId: 'turn-1' + }) + ).resolves.toMatchObject(UNLOADABLE) + await host.flushAllStreamedEvents() +}) + +// Startup listed the chat from its per-chat file; the send's write pays the copy it owes. +it('refuses a send to a restored chat as unloadable when the copy it owes does not verify', async () => { + const host = await relaunchWithMismatchedCopy() + await host.restoreReadableSessions([SESSION]) + expect(host.hasSession(SESSION)).toBe(true) + + await expect(send(host)).resolves.toMatchObject(UNLOADABLE) + await host.flushAllStreamedEvents() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts new file mode 100644 index 000000000000..3fb865f50ec7 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts @@ -0,0 +1,61 @@ +// Damage SQLite reports in the middle of a session: the write that meets it fails and says the +// chat cannot be loaded, the agent can still be stopped, and nothing is renamed or rebuilt. + +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 type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState +} from './structured-agent-session-host-test-harness' +import { hostTestMessage } from './structured-agent-session-host-test-data' + +let root: string +let host: StructuredAgentSessionHost +let cancelTurn: Mock + +beforeEach(() => { + ;({ root, host, cancelTurn } = hostTestState()) +}) + +afterEach(() => vi.restoreAllMocks()) + +// T-corrupt-midsession. +it('refuses a send as corrupt when SQLite reports damage, and still stops the agent', async () => { + await attach() + const files = await readdir(root, { recursive: true }) + const damaged = Object.assign(new Error('database disk image is malformed'), { + code: 'ERR_SQLITE_ERROR', + errcode: 11 + }) + vi.spyOn(JournalHostDatabase.prototype, 'transaction').mockImplementation(() => { + throw damaged + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + const body = hostTestMessage('after the damage') + const sent = await host.send(CALLER, { envelope: envelope('agentSession.send', { body }), body }) + + expect(sent).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Unable to load this chat.', + 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) + expect(cancelTurn).toHaveBeenCalledTimes(1) + expect(await readdir(root, { recursive: true })).toEqual(files) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts deleted file mode 100644 index f6ca0bc4a4e1..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Journal handle ownership across the wire layer. -// -// Every one of these sites is reached only when something has already gone -// wrong, so a happy-path assertion proves nothing about them. On POSIX a leak -// is silent; the rename/remove pair below is the half that actually fails on -// Windows. - -import { access, mkdtemp, rename, 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 type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type * as JournalLegacyImport from '../agent-session-journal/journal-legacy-import' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' -import { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' -import { closeStructuredAgentSessionConversationUnderSerialize } from './structured-agent-session-host-lifetime' -import { tearDownStructuredAgentSessionHost } from './structured-agent-session-host-teardown' -import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' - -const legacyImport = vi.hoisted(() => ({ throws: false })) - -vi.mock('../agent-session-journal/journal-legacy-import', async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - importLegacyTranscriptIntoJournal: async ( - input: Parameters[0] - ) => { - if (legacyImport.throws) { - throw new Error('legacy import threw instead of reporting a failure') - } - return actual.importLegacyTranscriptIntoJournal(input) - } - } -}) - -const SESSION = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: SESSION, - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: SESSION } -} - -let root: string -let journalDir: string -const journals = createTrackedJournalOpener() - -async function exists(path: string): Promise { - return access(path) - .then(() => true) - .catch(() => false) -} - -async function expectNothingHoldsTheDirectory(directory: string): Promise { - const dbPath = journalDatabaseFile(directory) - expect(await exists(`${dbPath}-wal`)).toBe(false) - expect(await exists(`${dbPath}-shm`)).toBe(false) - const moved = `${directory}-moved` - await rename(directory, moved) - await rm(moved, { recursive: true }) -} - -function hostSession(journal: AgentSessionJournal): StructuredAgentSessionHostSession { - return { - journal, - params: {} as StructuredAgentSessionHostSession['params'], - child: null - } -} - -beforeEach(async () => { - legacyImport.throws = false - root = await mkdtemp(join(tmpdir(), 'orca-wire-handles-')) - journalDir = join(root, 'journal') -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('site 6: recovery rehydration', () => { - it('closes the journal it opened when the legacy import throws', async () => { - const seeded = await journals.open({ identity: IDENTITY, journalDir }) - for (let ordinal = 1; ordinal <= 3; ordinal += 1) { - await seeded.appendItem( - { provider: 'codex', threadId: SESSION, turnId: 'turn-1', ordinal }, - { kind: 'status', text: `seed-${ordinal}` }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - } - await seeded.close() - // Punch a hole in the middle so recovery takes the `journal_corrupt` branch. - const { openJournalDatabase } = await import('../agent-session-journal/journal-database') - const opened = openJournalDatabase(journalDatabaseFile(journalDir)) - opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(3) - opened.db.close() - legacyImport.throws = true - - await expect( - openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - journalDir, - fence: 1, - historyFilePath: join(root, 'missing.jsonl') - }) - ).rejects.toThrow('legacy import threw') - await expectNothingHoldsTheDirectory(journalDir) - }) -}) - -describe('sites 9 and 10: closing a conversation handle', () => { - it('drops the map entry before the close, and releases the handle', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir }) - const sessions = new Map([[SESSION, hostSession(journal)]]) - const order: string[] = [] - const close = journal.close.bind(journal) - journal.close = async () => { - // A lock-free reader arriving now must find no entry, never a closing handle. - order.push(sessions.has(SESSION) ? 'close-while-indexed' : 'close-after-delete') - await close() - order.push('closed') - } - - await expect( - closeStructuredAgentSessionConversationUnderSerialize( - { sessions, closeStatus: () => order.push('status') }, - SESSION - ) - ).resolves.toBe(true) - - expect(order).toEqual(['status', 'close-after-delete', 'closed']) - expect(sessions.size).toBe(0) - await expectNothingHoldsTheDirectory(journalDir) - }) - - it('surfaces a rejected close to its caller', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir }) - const sessions = new Map([[SESSION, hostSession(journal)]]) - const close = journal.close.bind(journal) - journal.close = () => Promise.reject(new Error('close rejected')) - - await expect( - closeStructuredAgentSessionConversationUnderSerialize( - { sessions, closeStatus: () => undefined }, - SESSION - ) - ).rejects.toThrow('close rejected') - journal.close = close - }) -}) - -describe('site 11: host teardown is failure-complete', () => { - async function twoSessions(): Promise> { - const first = await journals.open({ identity: IDENTITY, journalDir }) - const second = await journals.open({ - identity: { ...IDENTITY, sessionId: `${SESSION}-b` }, - journalDir: join(root, 'journal-b') - }) - return new Map([ - [SESSION, hostSession(first)], - [`${SESSION}-b`, hostSession(second)] - ]) - } - - it('closes every journal and clears the map on the happy path', async () => { - const sessions = await twoSessions() - const acknowledgeSessionRelease = vi.fn() - await tearDownStructuredAgentSessionHost({ - phases: [], - sessions, - acknowledgeSessionRelease - }) - - expect(sessions.size).toBe(0) - expect(acknowledgeSessionRelease.mock.calls).toEqual([[SESSION], [`${SESSION}-b`]]) - await expectNothingHoldsTheDirectory(journalDir) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) - - // Against a trailing-statement design this case fails: `flushAllEventSinks` - // throws by design, so the close would be skipped on exactly the leaking path. - it('still closes every journal when a teardown phase throws', async () => { - const sessions = await twoSessions() - const barrierError = new Error('sink barrier failed') - - await expect( - tearDownStructuredAgentSessionHost({ - phases: [ - { - name: 'flush-event-sinks', - run: () => { - throw barrierError - } - } - ], - sessions - }) - ).rejects.toMatchObject({ errors: [barrierError] }) - - expect(sessions.size).toBe(0) - await expectNothingHoldsTheDirectory(journalDir) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) - - it('keeps the entry whose close rejected, and surfaces the rejection', async () => { - const sessions = await twoSessions() - const acknowledgeSessionRelease = vi.fn() - const failing = sessions.get(SESSION) - const closeError = new Error('close rejected') - if (failing) { - sessions.set(SESSION, { - ...failing, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close`, and this map is a plain `Map` that binds no delivery. - journal: { - close: () => Promise.reject(closeError) - } as unknown as AgentSessionJournal - }) - } - - await expect( - tearDownStructuredAgentSessionHost({ phases: [], sessions, acknowledgeSessionRelease }) - ).rejects.toMatchObject({ errors: [closeError] }) - - // Only the failure stays indexed — `status === 'fulfilled'`, not "settled". - expect([...sessions.keys()]).toEqual([SESSION]) - expect(acknowledgeSessionRelease).toHaveBeenCalledExactlyOnceWith(`${SESSION}-b`) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) -}) 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 8a43b1a8eff3..4dfbac37c42c 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 @@ -27,6 +27,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -117,7 +118,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 213126fb5eb5..cea81c6c4fe4 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 @@ -22,6 +22,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -50,7 +51,7 @@ function openHost(): void { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW, 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 95861c81f29f..ea543785dde5 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 @@ -35,6 +35,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const PROVIDER_ROW = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1' } @@ -89,7 +90,7 @@ beforeEach(async () => { setOption: vi.fn(async () => undefined), backgroundTaskState: () => backgroundTasks }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 094ddffdcb27..71750505fef1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -29,7 +29,7 @@ async function context(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: join(hostTestState().root, 'settlement') + stateDirectory: join(hostTestState().root, 'settlement') }), fence: 1, adapter: 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 3eae8a7ae08f..7445c1e3894c 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 @@ -19,6 +19,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -124,7 +125,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: router, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-native', now: () => NOW 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 9da47ae1f22d..340b35cf21ce 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 @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -88,7 +89,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, 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 89772d454690..fd41ba7890f2 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 @@ -16,6 +16,7 @@ import { } from './structured-agent-session-attach' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -84,8 +85,8 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', 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 348d5a3d90cb..204ffba30da6 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 @@ -14,6 +14,7 @@ import { } from './structured-agent-session-attach' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -86,8 +87,7 @@ describe('processless structured session reservation', () => { performAttach({ store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -134,8 +134,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -174,8 +174,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-drift', claimKeyId: 'key-1', @@ -227,8 +227,7 @@ describe('processless structured session reservation', () => { performAttach({ store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -294,8 +293,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index e0ae5da9d812..b9a7bc7c5c47 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -36,7 +36,7 @@ afterEach(async () => { async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: string }> { root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const item = await journal.appendItem( PROMPT_IDENTITY, { 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 30e81ab2d1ec..74ae75e45d93 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,6 @@ 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 { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' @@ -38,6 +37,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -89,7 +89,7 @@ function startHost(): void { setOption: vi.fn(async () => undefined), ...adapterExtras }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW @@ -332,10 +332,7 @@ describe('settling an earlier child before the next one takes its message', () = agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: journalDirectoryFor(root, { - workspaceId: HOST_TEST_LOCATION.workspaceId, - sessionId: SESSION - }) + database: openTestJournalHostDatabase(root) }) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, 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 5da0861aa197..e5025551360b 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 @@ -14,6 +14,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -56,7 +57,7 @@ function createHost( const host = new StructuredAgentSessionHost({ store, adapter: claudeAdapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner, 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 b18369838698..892e45a4ad0d 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 @@ -21,6 +21,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -76,7 +77,7 @@ beforeEach(async () => { store, // The production router is what declares create support; the bare adapter only knows locations. adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 fcc167cbb67e..739e64e94ece 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 @@ -25,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' export const QUEUED_RIG_CALLER = { callerKey: 'client-1' } @@ -86,7 +87,7 @@ export async function createQueuedMessageTestRig() { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-1', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts index 273af6dfee65..b6d9d2a90beb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-messages.ts @@ -221,7 +221,9 @@ export async function maybeQueueStructuredAgentSessionSend( if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { return null } + // A newer Orca's journal takes no new draft: the immediate path refuses the send. if ( + ctx.journal.isReadOnly || !shouldQueueStructuredAgentSessionSend({ journal: ctx.journal, record: context.deps.store.getRecord(ctx.sessionId), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 89fc2b0c432e..9da974a44a43 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -16,6 +16,10 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' +import { + isJournalWrittenByNewerOrca, + journalOpenRefusal +} from '../agent-session-journal/journal-open-failure' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import type { MutationPlan } from './structured-agent-session-mutation-plans' import { @@ -142,11 +146,25 @@ function mutateQueued( context, caller, envelope, - plan, + { ...plan, run: (ctx) => refusingNewerOrcaJournal(plan.run(ctx)) }, openForWrite(context, envelope) ) } +/** A newer Orca's journal refuses a draft write with the words a send gets there. */ +async function refusingNewerOrcaJournal( + run: Promise> +): Promise> { + try { + return await run + } catch (error) { + if (isJournalWrittenByNewerOrca(error)) { + return { ok: false, refusal: journalOpenRefusal(error) } + } + throw error + } +} + /** * Send-now. It overrides ONLY queue policy — FIFO order, pause, the busy-turn * wait — through the same send block and pending-prompt gates as any send; diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts new file mode 100644 index 000000000000..2c5332db1bb3 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts @@ -0,0 +1,93 @@ +// A chat journal a newer Orca wrote opens read-only here: its cards still show, and every queue +// write (a queued send, Send-now, Delete, Resume) is refused with the words a send gets there. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../agent-session-journal/journal-open-failure' +import { HOST_TEST_SESSION } from './structured-agent-session-host-test-data' +import { + createQueuedMessageTestRig, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' + +const REFUSED_BY_NEWER_ORCA = { + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalWrittenByNewerOrca' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } +} + +let rig: QueuedMessageTestRig + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +/** The chat reopens on a database a newer Orca stamped. The host's database object is the one + * the rig built, so its open-time verdict is set in place; every store write checks that. */ +async function reopenOnNewerOrcaDatabase(close: () => Promise): Promise { + await close() + Object.defineProperty(openTestJournalHostDatabase(rig.root), 'readOnly', { value: true }) +} + +/** A read-only journal's history answers as a `schema_unreadable` reset carrying its page. */ +async function readOnlyQueue() { + const answer = await rig.host.history({ sessionId: HOST_TEST_SESSION, direction: 'tail' }) + expect(answer).toMatchObject({ ok: false, reset: 'schema_unreadable' }) + const page = 'page' in answer ? answer.page : undefined + return { + cards: (page?.queuedMessages ?? []).map(({ messageId, state }) => ({ messageId, state })), + pause: page?.queuePause ?? null + } +} + +async function twoCardsBehindWork() { + await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + return { + first, + second, + cards: [ + { messageId: first, state: 'waiting' }, + { messageId: second, state: 'waiting' } + ] + } +} + +describe("a newer Orca's journal", () => { + it('shows the cards, and refuses a queued send, Send-now and Delete with the update words', async () => { + const { first, second, cards } = await twoCardsBehindWork() + await reopenOnNewerOrcaDatabase(() => rig.host.close(HOST_TEST_SESSION)) + expect(await readOnlyQueue()).toEqual({ cards, pause: null }) + + // The waiting cards would queue this send behind them; the journal takes no new draft. + expect(await rig.send('third', 'queue-if-active').result).toMatchObject(REFUSED_BY_NEWER_ORCA) + expect(await rig.sendNow(first)).toMatchObject(REFUSED_BY_NEWER_ORCA) + expect(await rig.deleteQueued(second)).toMatchObject(REFUSED_BY_NEWER_ORCA) + + expect(await readOnlyQueue()).toEqual({ cards, pause: null }) + }) + + it('refuses Resume of the queue a restart paused with the update words', async () => { + const { cards } = await twoCardsBehindWork() + await reopenOnNewerOrcaDatabase(() => rig.restartHostProcess()) + expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } }) + + expect(await rig.resume()).toMatchObject(REFUSED_BY_NEWER_ORCA) + + expect(await readOnlyQueue()).toEqual({ cards, pause: { reason: 'restarted' } }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 514008e49f9b..83238e95d74f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -13,6 +13,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' const SESSION_ID = 'codex_read_restore_fixture' @@ -50,7 +54,7 @@ const store = { let journalRoot: string const openDeps = () => ({ store, - journalRoot, + journalDatabase: openTestJournalHostDatabase(journalRoot), adapter: {} }) const opened: AgentSessionJournal[] = [] @@ -71,6 +75,7 @@ beforeEach(async () => { afterEach(async () => { await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) + closeTestJournalHostDatabases() await rm(journalRoot, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 0e1441b729c7..cf80396d8204 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,6 +1,7 @@ -import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' import { existsSync } from 'node:fs' -import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' +import { legacyJournalDatabaseFile } from '../agent-session-journal/journal-paths' +import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { openStructuredAgentSessionConversationJournal, type OpenedStructuredAgentSessionConversation, @@ -20,13 +21,22 @@ export async function restoreStructuredAgentSessionRead( if (!record) { return null } - const journalDir = journalDirectoryFor(deps.journalRoot, { - workspaceId: record.location.workspaceId, - sessionId - }) - // A session still in the pre-SQLite format has no `journal.db`; the open imports it. - if (!existsSync(journalDatabaseFile(journalDir)) && !findJournalFileFormatRemnant(journalDir)) { - return null + const database = deps.journalDatabase + if (readJournalSessionEpoch(database.db, sessionId) === null) { + // Not in the host's database yet: its history may still sit in a per-chat file the open + // imports, or in the pre-SQLite format the open explains. + const legacyDirectory = database.legacyDirectoryFor({ + workspaceId: record.location.workspaceId, + sessionId + }) + if ( + !existsSync(legacyJournalDatabaseFile(legacyDirectory)) && + !findJournalFileFormatRemnant(legacyDirectory) + ) { + return null + } } - return openStructuredAgentSessionConversationJournal(deps, record) + return openStructuredAgentSessionConversationJournal(deps, record, { + deferPerSessionImport: true + }) } 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 d1cc9408a62e..5eaedfffb384 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 @@ -1,5 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() })) @@ -9,6 +16,13 @@ vi.mock('./structured-agent-session-restart-restore', () => ({ import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +// The restore pool is mocked; the host database only fills the deps' shape. +const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-readable-restorer-')) +afterAll(() => { + closeTestJournalHostDatabases() + rmSync(stateDirectory, { recursive: true, force: true }) +}) + describe('StructuredAgentSessionReadableRestorer', () => { beforeEach(() => { restoreOnRestart.mockReset().mockResolvedValue(undefined) @@ -21,7 +35,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: { store: { getRecord: () => null, listRecords: () => records }, - journalRoot: '/tmp/journals', + journalDatabase: openTestJournalHostDatabase(stateDirectory), adapter: {} }, supportsRecord: () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index 7a90850dfc6a..f7d26c154f1f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -13,7 +13,7 @@ import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' import { AgentHookServer, _internals } from '../../agent-hooks/server' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { settleStaleStructuredAgentSessionState, @@ -55,7 +55,7 @@ async function sessionWithRunningTurn() { providerHandle: { kind: 'codex', threadId: THREAD } }, now: () => clock, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { provider: 'orca', clientMessageId: 'prompt-1' }, 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 2da22f958733..4728d4256de2 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 @@ -20,6 +20,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -81,7 +82,7 @@ function openHost(overrides: Partial = {}): void host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW, 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 4043e27b30c7..4c2db496c3cf 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 @@ -27,6 +27,7 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -91,7 +92,7 @@ async function createHarness(options: { attached?: boolean } = {}) { const host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 3ea116fa361d..782c1f161156 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 @@ -30,6 +30,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -159,7 +160,7 @@ export async function createRestTestRig( answerPrompt: async ({ commit }) => commit(), setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner: async () => ({ outcome: 'pid-absent' }), 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 6566105ee87a..30eae55f36a9 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 @@ -29,6 +29,7 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -136,7 +137,7 @@ export async function interruptedRestart( } : {}) }, - journalRoot: previous.root, + journalDatabase: openTestJournalHostDatabase(previous.root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', probeOwner, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts index aefa34f4b673..a9aeb826a7f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts @@ -3,9 +3,8 @@ import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import { rename, writeFile, rm } from 'node:fs/promises' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -36,24 +35,16 @@ it('deletes the offer once the user sends their own message in that chat', async // A journal this host cannot read decides nothing, so it must not end the offer. it('keeps the offer when the chat cannot be read on this host', async () => { - const { host, root, store } = await interruptedRestart('submission') + const { host, root } = await interruptedRestart('submission') const capsule = new AgentSessionRecoveryCapsule(root) - const location = store.getRecord(SESSION)?.location - if (!location) { - throw new Error('missing session record') - } - // A file where the journal directory belongs: this host cannot open the conversation at all. - const journalDir = journalDirectoryFor(root, { - workspaceId: location.workspaceId, - sessionId: SESSION - }) - await rename(journalDir, `${journalDir}.aside`) - await writeFile(journalDir, 'not a journal') + // This host cannot open the conversation at all. + const open = vi + .spyOn(AgentSessionJournal.prototype, 'open') + .mockRejectedValue(Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR' })) try { expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) } finally { - await rm(journalDir) - await rename(`${journalDir}.aside`, journalDir) + open.mockRestore() } expect(await capsule.list(NOW)).toHaveLength(1) expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) 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 90875832c8f2..c7d04ea64cc4 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 @@ -1,6 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' const { restoreRead } = vi.hoisted(() => ({ restoreRead: vi.fn() @@ -12,9 +19,16 @@ vi.mock('./structured-agent-session-read-restore', () => ({ import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +// The restore under test is mocked; the host database only fills the deps' shape. +const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-restart-restore-')) +afterAll(() => { + closeTestJournalHostDatabases() + rmSync(stateDirectory, { recursive: true, force: true }) +}) + const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, - journalRoot: '/tmp/journals', + journalDatabase: openTestJournalHostDatabase(stateDirectory), adapter: {} } @@ -64,6 +78,44 @@ describe('restart journal restoration', () => { expect(peak).toBe(4) }) + it('lets the event loop run between chats', async () => { + // Counts turns of the event loop while the restore runs; each open below is synchronous. + let turns = 0 + let ticking = true + const tick = (): void => { + turns += 1 + if (ticking) { + setImmediate(tick) + } + } + setImmediate(tick) + const turnsSeen = new Set() + restoreRead.mockImplementation(async () => { + turnsSeen.add(turns) + return null + }) + const records = Array.from( + { length: 12 }, + // 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 + ) + + await restoreStructuredAgentSessionsOnRestart({ + openDeps: NO_OPEN_DEPS, + records, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined + }) + ticking = false + + expect(restoreRead).toHaveBeenCalledTimes(records.length) + // At most one chat per worker between two turns: never the whole restore in one task. + expect(turnsSeen.size).toBeGreaterThanOrEqual(records.length / 4) + }) + it('settles what a gone generation left running after recovery resolution, before publishing', async () => { const calls: string[] = [] const params: AgentSessionAttachParams = { 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 d54687c25a64..4e4b7f1e5ddd 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 @@ -11,6 +11,7 @@ // with no client attached and nothing on screen. A child now exists because work asked for it — a // send, through the delivery loop — not because a record survived on disk. +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' @@ -72,7 +73,9 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( export async function restoreStructuredAgentSessionsOnRestart( input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } ): Promise { - await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, ({ sessionId }) => - restoreOneStructuredAgentSessionRead(input, sessionId) - ) + 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) + }) } 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 524d8943fac2..87e87a408c32 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 @@ -25,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -57,7 +58,7 @@ function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost const host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner: async () => ({ 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 new file mode 100644 index 000000000000..226e6536c9c1 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -0,0 +1,523 @@ +// Startup restore lists a chat that is still in its per-chat file from a read-only fold of that +// file, and copies nothing: the copy waits for the chat's first real read or write, which is +// restore's own only for a chat the last run left mid-work. Restore stays the cost it was when +// every chat had its own file, and opens no file it does not restore. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-latest-request' +import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import Database from '../../sqlite/sync-database' +import type * as SyncDatabaseModule from '../../sqlite/sync-database' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase, + readTestJournalRows +} from '../agent-session-journal/journal-host-database-test-support' +import { + journalDirectoryFor, + legacyJournalDatabaseFile +} from '../agent-session-journal/journal-paths' +import { + readJournalSessionEpoch, + type JournalStoredRow +} from '../agent-session-journal/journal-row-table' +import { previewPerSessionJournal } from '../agent-session-journal/journal-per-session-import' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { journalIdentityFor } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { createStructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ + readOnlyOpens: new Array(), + openReadOnly: new Set() +})) + +// Every read-only open is of a per-chat file: the host's own database opens read-write. +vi.mock('../../sqlite/sync-database', async (importOriginal) => { + const actual = await importOriginal() + class RecordingDatabase extends actual.default { + constructor(...args: ConstructorParameters) { + super(...args) + if (args[1]?.readonly) { + readOnlyOpens.push(String(args[0])) + openReadOnly.add(this) + } + } + + override close(): void { + openReadOnly.delete(this) + super.close() + } + } + return { ...actual, default: RecordingDatabase } +}) + +const WORKSPACE_ID = 'repo-1::/tmp/workspace' +const PROMPT = 'add a retry' + +let root: string +let clock = 1_000 + +function recordFor(sessionId: string): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore reads only the id, location, provider, handle chain, account home and lease. + return { + schemaVersion: 2, + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + providerHandleChain: [ + { + linkId: `codex-1-${sessionId}`, + handle: { provider: 'codex', threadId: `thread-${sessionId}` }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + createdAt: 1, + updatedAt: 2, + lease: { sessionId, runtimeKind: 'native', runtimeFence: 1 } + } as unknown as AgentSessionRecord +} + +function identityFor(sessionId: string) { + const record = recordFor(sessionId) + return journalIdentityFor( + record, + attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: 1 }) + ) +} + +function legacyDirFor(sessionId: string): string { + return journalDirectoryFor(root, { workspaceId: WORKSPACE_ID, sessionId }) +} + +/** What the run before the upgrade left open in a chat, for its restore to settle. */ +type MidWork = 'running tool call' | 'unresolved send' + +/** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ +async function seedLegacyChat( + sessionId: string, + replies = 1, + midWork?: MidWork +): Promise { + const scratch = join(root, `scratch-${sessionId}`) + const identity = identityFor(sessionId) + const journal = await openAgentSessionJournal({ + identity, + database: openTestJournalHostDatabase(scratch), + now: () => (clock += 1) + }) + await journal.appendSubmission({ + clientMessageId: `client-${sessionId}`, + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, + fence: 1, + handoverRecorded: true + }) + if (midWork === 'running tool call') { + await journal.appendItem( + { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal: 50 }, + { kind: 'tool-call', name: 'Read', input: {}, state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await journal.resolveDispatch( + midWork === 'unresolved send' + ? // Handed over, and never answered. + { + clientMessageId: `client-${sessionId}`, + fence: 1, + state: 'pending', + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + : { + clientMessageId: `client-${sessionId}`, + fence: 1, + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: `thread-${sessionId}`, + turnId: 't', + ordinal: 0 + } + } + ) + for (let ordinal = 1; ordinal <= replies; ordinal += 1) { + await journal.appendItem( + { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `reply ${ordinal}` }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + } + await journal.close() + const rows = readTestJournalRows( + openTestJournalHostDatabase(scratch).db, + sessionId, + journal.epoch + ) + const path = legacyJournalDatabaseFile(legacyDirFor(sessionId)) + await mkdir(dirname(path), { recursive: true }) + const db = new Database(path) + try { + db.pragma('journal_mode = WAL') + db.exec(` +CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, + ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, + content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) + db.pragma('user_version = 2') + const insert = db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + for (const row of rows) { + insert.run(sessionId, row.epoch, row.seq, row.ts, row.rowJson) + } + db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(sessionId, rows[0]!.epoch, 1) + } finally { + db.close() + } + return rows +} + +function legacyFile(sessionId: string): string { + return legacyJournalDatabaseFile(legacyDirFor(sessionId)) +} + +function hostDb(): Database.Database { + return openTestJournalHostDatabase(root).db +} + +function importCount(): number { + return Number(hostDb().prepare('SELECT count(*) AS n FROM journal_imports').get()?.n) +} + +type LifetimeHost = Parameters[0] + +function conversations(): StructuredAgentSessionConversations { + return new StructuredAgentSessionConversations({ + deliver: () => undefined, + onDeliveryError: () => undefined, + now: () => clock + }) +} + +async function restore(sessionIds: readonly string[]) { + const sessions = conversations() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and an open conversation's read touch only `getRecord` and `listRecords`. + const store = { + getRecord: (sessionId: string) => recordFor(sessionId), + listRecords: () => sessionIds.map(recordFor) + } as unknown as AgentSessionRecordStore + const deps = { store, adapter: {}, journalDatabase: openTestJournalHostDatabase(root) } + await restoreStructuredAgentSessionsOnRestart({ + openDeps: deps, + records: sessionIds.map(recordFor), + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: (sessionId) => sessions.has(sessionId), + onReadable: (sessionId, opened) => { + sessions.set(sessionId, opened.session) + } + }) + const context = (): StructuredAgentSessionLifetimeContext => + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reaching an open conversation reads only `deps.store` and `deps.adapter`. + ({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext + const lifetimeOver = (listed: LifetimeHost['sessions'], open: LifetimeHost['open']) => + createStructuredAgentSessionConversationLifetime({ + context, + sessions: listed, + serialize: async (_sessionId, task) => task(), + open, + deliveryActive: () => false, + closeStatus: () => undefined + }) + const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) + return { sessions, lifetime, lifetimeOver } +} + +function texts(items: readonly { body: unknown }[]): string { + return JSON.stringify(items.map((entry) => entry.body)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-restore-without-import-')) + clock = 1_000 + readOnlyOpens.length = 0 +}) + +afterEach(async () => { + vi.restoreAllMocks() + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +describe('startup restore of chats still in their per-chat files', () => { + it('lists every restored chat without copying one, and opens only their files', async () => { + const restored = ['chat-a', 'chat-b', 'chat-c'] + const rows = new Map() + for (const sessionId of [...restored, 'chat-closed']) { + rows.set(sessionId, await seedLegacyChat(sessionId)) + } + readOnlyOpens.length = 0 + const before = new Map( + await Promise.all( + restored.map( + async (sessionId) => [sessionId, await readFile(legacyFile(sessionId))] as const + ) + ) + ) + + const { sessions } = await restore(restored) + + // Each file was closed by the fold that read it, so nothing holds it open for a later rename. + expect(openReadOnly.size).toBe(0) + // Read, never written: the same bytes, in the same place, beside only SQLite's own WAL files. + for (const sessionId of restored) { + expect((await readFile(legacyFile(sessionId))).equals(before.get(sessionId)!)).toBe(true) + const entries = await readdir(legacyDirFor(sessionId)) + expect(entries.filter((name) => !/^journal\.db(-wal|-shm)?$/.test(name))).toEqual([]) + } + + expect([...sessions.keys()].sort()).toEqual(restored) + for (const sessionId of restored) { + const journal = sessions.get(sessionId)!.journal + expect(journal.cursor().sequence).toBe(rows.get(sessionId)!.length) + expect(texts(journal.snapshot().items)).toContain('reply 1') + expect(readJournalSessionEpoch(hostDb(), sessionId)).toBeNull() + expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true) + } + for (const sessionId of restored) { + await sessions.get(sessionId)!.journal.close() + } + expect(importCount()).toBe(0) + // One read of each restored chat's file, and none of the chat that was not restored. + expect(readOnlyOpens.sort()).toEqual( + restored.map((sessionId) => legacyJournalDatabaseFile(legacyDirFor(sessionId))).sort() + ) + }) + + // Restore copies a chat only to write to it itself, settling what the last run left open (a + // turn, tool call, approval, question, send or subagent). A settled chat is never copied here. + it.each(['running tool call', 'unresolved send'] as const)( + 'copies during restore only a chat it settles (%s)', + async (midWork) => { + const rows = await seedLegacyChat('chat-mid-work', 1, midWork) + await seedLegacyChat('chat-settled') + + const { sessions } = await restore(['chat-mid-work', 'chat-settled']) + + // Restore wrote a settlement to the chat left mid-work, so it copied that chat first. + const settled = sessions.get('chat-mid-work')!.journal + expect(settled.cursor().sequence).toBeGreaterThan(rows.length) + expect( + readTestJournalRows(hostDb(), 'chat-mid-work', rows[0]!.epoch).slice(0, rows.length) + ).toEqual(rows) + expect(existsSync(legacyFile('chat-mid-work'))).toBe(false) + expect(readJournalSessionEpoch(hostDb(), 'chat-settled')).toBeNull() + expect(existsSync(legacyFile('chat-settled'))).toBe(true) + expect(importCount()).toBe(1) + } + ) + + it('lets other work run while it reads a large per-chat file', async () => { + // Past one batch of the file's rows. + const rows = await seedLegacyChat('chat-a', 520) + let turns = 0 + let ticking = true + const tick = (): void => { + turns += 1 + if (ticking) { + setImmediate(tick) + } + } + + setImmediate(tick) + const folded = await previewPerSessionJournal({ + database: openTestJournalHostDatabase(root), + identity: identityFor('chat-a'), + legacyDirectory: legacyDirFor('chat-a') + }) + ticking = false + + expect(turns).toBeGreaterThan(0) + expect(folded?.state.lastSequence).toBe(rows.length) + expect(openReadOnly.size).toBe(0) + }) + + // T-B3 for a chat restore did not copy: the offer taken before the upgrade still stands. + it('offers the restorable turn of a chat it did not copy', async () => { + const rows = await seedLegacyChat('chat-a') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. + const marker = { + sessionId: 'chat-a', + journalCursor: { epoch: rows[0]!.epoch, sequence: rows.length } + } as AgentSessionResumeMarker + + const { sessions } = await restore(['chat-a']) + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + sessions, + now: () => clock, + enqueue: (operation) => operation() + }) + + expect(withdrawal.movedOn(marker)).toBe(false) + expect( + latestStructuredAgentSessionPrompt(sessions.get('chat-a')!.journal.snapshot().items) + ).toBe(PROMPT) + expect(importCount()).toBe(0) + }) + + it('copies a chat on its first read, and serves its history verbatim', async () => { + const rows = await seedLegacyChat('chat-a') + const { lifetime, sessions } = await restore(['chat-a']) + const listed = sessions.get('chat-a')!.journal.snapshot() + + const journal = (await lifetime.conversation('chat-a')).journal + const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) + + // The copied chat is the chat restore listed, item for item. + expect(journal.snapshot()).toEqual(listed) + + expect(importCount()).toBe(1) + expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) + expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) + expect(existsSync(legacyDirFor('chat-a'))).toBe(false) + }) + + // A read queued behind restore's open of the same chat reaches it through its own open, not the + // listing: it still waits for the copy, and reads the chat from the one database. + it('makes a read whose open lands on the chat restore opened wait for its copy', async () => { + const rows = await seedLegacyChat('chat-a') + const { sessions, lifetimeOver } = await restore(['chat-a']) + const restored = sessions.get('chat-a')! + const lifetime = lifetimeOver(conversations(), async () => restored) + + const { journal } = await lifetime.conversation('chat-a') + const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) + + expect(importCount()).toBe(1) + expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) + }) + + // Whether the read finds the chat restore listed, or opens it and lands on the one restore opened. + it.each(['listed', 'opened'] as const)( + 'refuses a read of the chat restore %s when its copy meets damage, never with the storage text', + async (reach) => { + await seedLegacyChat('chat-a') + const { sessions, lifetime, lifetimeOver } = await restore(['chat-a']) + const restored = sessions.get('chat-a')! + await rm(legacyDirFor('chat-a'), { recursive: true, force: true }) + await mkdir(legacyDirFor('chat-a'), { recursive: true }) + await writeFile(legacyFile('chat-a'), 'not a database, and never was one') + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reader = + reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored) + + const read = reader.conversation('chat-a') + + await expect(read).rejects.toBeInstanceOf(AgentSessionRefusalError) + await expect(read).rejects.toMatchObject({ + message: 'agent_session_journal_unreadable', + refusal: { details: { reason: 'journalCorrupt' } } + }) + } + ) + + it('copies a chat before its first write, and the write lands after its history', async () => { + const rows = await seedLegacyChat('chat-a') + const { sessions } = await restore(['chat-a']) + const journal = sessions.get('chat-a')!.journal + + await journal.appendItem( + { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const stored = readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch) + expect(stored.slice(0, rows.length)).toEqual(rows) + expect(stored).toHaveLength(rows.length + 1) + expect(importCount()).toBe(1) + }) + + it("copies a chat before its first queued message, which lands as that chat's draft", async () => { + const rows = await seedLegacyChat('chat-a') + const { sessions } = await restore(['chat-a']) + const journal = sessions.get('chat-a')!.journal + expect(journal.importPending).toBe(true) + + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'later' }] }, + fingerprint: 'fp-draft-1', + hostInstance: 'proc-1' + }) + + expect(journal.importPending).toBe(false) + expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) + expect(existsSync(legacyFile('chat-a'))).toBe(false) + expect(importCount()).toBe(1) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + + it('never shows a read racing the copy a partly copied chat', async () => { + // Past one import batch, so the copy yields to other work between batches. + const rows = await seedLegacyChat('chat-a', 520) + const { lifetime, sessions } = await restore(['chat-a']) + const journal = sessions.get('chat-a')!.journal + const cursor = { epoch: rows[0]!.epoch, sequence: 0 } + const seen: { published: boolean; copied: number; folded: number }[] = [] + const reads: Promise[] = [] + let ticking = true + const tick = (): void => { + seen.push({ + published: readJournalSessionEpoch(hostDb(), 'chat-a') !== null, + copied: Number(hostDb().prepare('SELECT count(*) AS n FROM journal_rows').get()?.n), + folded: journal.cursor().sequence + }) + reads.push( + lifetime.conversation('chat-a').then(({ journal: read }) => { + const since = read.readSince(cursor) + return since.ok ? since.rows.length : -1 + }) + ) + if (ticking) { + setImmediate(tick) + } + } + setImmediate(tick) + + await lifetime.conversation('chat-a') + ticking = false + await new Promise((resolve) => setImmediate(resolve)) + + // The copy was under way while other work ran, and none of it saw part of the chat. + expect(seen.some((turn) => !turn.published && turn.copied > 0)).toBe(true) + expect(seen.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) + expect(seen.every((turn) => turn.folded === rows.length)).toBe(true) + expect(new Set(await Promise.all(reads))).toEqual(new Set([rows.length])) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index d3a262d0511e..d00f6a244657 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 @@ -24,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -78,7 +79,7 @@ function openHost(): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }), 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 22be823ad782..7b4baa03df87 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 @@ -27,6 +27,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const caller = { callerKey: 'desktop' } let directory: string @@ -95,7 +96,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 2494535e019b..1925c13378ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -25,7 +25,7 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: root + stateDirectory: root }) }) 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 630e3fa386e9..3718a60a7a98 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 @@ -25,6 +25,7 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -75,7 +76,7 @@ async function relaunchAfterCrashMidTurn( const host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), acquire }, - journalRoot: relaunched, + journalDatabase: openTestJournalHostDatabase(relaunched), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', probeOwner: async () => probe, 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 259ad16c858e..e435110f1399 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 @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -79,7 +80,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index 59f265750d27..be29089991bd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -12,11 +12,7 @@ import { } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' -import { - classifyJournalOpenFailure, - type JournalOpenFailure -} from '../agent-session-journal/journal-open-failure' +import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import { structuredAgentSessionAwaitedCommand, type StructuredAgentSessionAwaitedCommandJournal @@ -69,20 +65,10 @@ export async function openConversationForWrite( return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } catch (error) { console.warn('[agent-session] opening the conversation for a write failed:', error) - const reason = classifyJournalOpenFailure(error) - return { - ok: false, - refusal: refuse('agent_session_journal_unreadable', { reason }, JOURNAL_OPEN_MESSAGE[reason]) - } + return { ok: false, refusal: journalOpenRefusal(error) } } } -// Released clients print a refusal's message for a send; it fits a Stop too. -const JOURNAL_OPEN_MESSAGE: Record = { - journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), - journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']) -} - /** The conversation a write lands in, opened when this host holds it closed. */ export function openForWrite( context: Pick, 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 f339aa1113fc..d8390feae1c4 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 @@ -27,6 +27,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -154,7 +155,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW 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 7602afa230be..27b408933b7c 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 @@ -24,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -112,7 +113,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -139,7 +140,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), historyFilePath }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -196,7 +197,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, now: () => NOW @@ -240,7 +241,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, // A host that cannot read another process's environment, so no scan can prove anything. @@ -270,7 +271,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, // A host that cannot read another process's environment, so no scan can prove anything. @@ -327,7 +328,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-restarted', probeOwner: async () => ({ outcome: 'pid-absent' }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 54acad4832ba..71c5df2692d6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -12,7 +12,7 @@ import type { import type { AgentSessionDeathEvidence } from '../../../shared/agent-session-record' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { runningTurnLifecycleRevisions, @@ -369,7 +369,7 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => 1_000 }) const child = { agentId: 'thread-child', producerKind: 'agent' as const } @@ -423,7 +423,7 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => now }) const command = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 1 } @@ -489,7 +489,7 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => now }) await journal.appendItem( @@ -560,7 +560,7 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => now }) await journal.appendItem( @@ -620,7 +620,7 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => now }) // Enough running turns that the settle writes two batches, and its retry two again. 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 5ce1fdb3694e..69bed14b7b12 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 @@ -25,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -140,7 +141,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts index 1731674baad3..03c490742ce3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusSink @@ -34,7 +34,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const session = indexedStatusFeedSession({ journal }) return new StructuredAgentSessionStatusFeed({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts index 78e10bec076d..545a0ebb4600 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it } from 'vitest' import type { AgentSessionStatusEvent } from '../../../shared/agent-session-wire' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -29,7 +29,7 @@ it('publishes each provider child even when a replacement has the same startup p agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: root }) const sessions = new Map>() const setChild = ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index 8576df8a95c6..c39228522454 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -11,7 +11,7 @@ import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -57,7 +57,7 @@ async function openFeed() { providerHandle: { kind: 'codex', threadId: THREAD } }, now: () => (clock += 100), - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index 8ff9de818a3d..ad23177bdd3a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -13,7 +13,7 @@ import type { import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { indexedStatusFeedSession as indexed } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionStatusFeed, @@ -59,7 +59,7 @@ async function openJournal(sessionId = SESSION, now?: () => number) { providerHandle: { kind: 'codex', threadId: 'thread-1' } }, now, - journalDir: join(root, sessionId) + stateDirectory: join(root, sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index ad081c6774fa..091e1f70feb9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { AgentHookServer } from '../../agent-hooks/server' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { @@ -35,7 +35,7 @@ async function openJournal(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index a0912e86dbc1..20b5038f1734 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -19,7 +19,7 @@ import { projectStructuredAgentSessionStatusSummary } from '../../../shared/stru import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -58,7 +58,7 @@ async function openSession() { providerHandle: { kind: 'codex', threadId: CODEX_THREAD } }, now: tick, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) // The roster the provider adapter reports, and the host's status row the feed writes into. const roster: { tasks: AgentSessionBackgroundTask[] } = { tasks: [] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index c39cfe44e1d4..cf31d1d47f1c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -14,11 +14,12 @@ import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + insertTestJournalRow +} from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -47,7 +48,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'activity-churn-journal') + stateDirectory: join(root, 'activity-churn-journal') }) const subscribers = new AgentSessionSubscribers() @@ -67,7 +68,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'checkpoint-journal') + stateDirectory: join(root, 'checkpoint-journal') }) const events: AgentSessionSubscribeEvent[] = [] @@ -106,7 +107,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'clock-journal') + stateDirectory: join(root, 'clock-journal') }) let now = 1_000 const events: AgentSessionSubscribeEvent[] = [] @@ -151,7 +152,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'catalog-journal') + stateDirectory: join(root, 'catalog-journal') }) let commands = [{ name: 'first', kind: 'skill' as const }] const events: AgentSessionSubscribeEvent[] = [] @@ -194,7 +195,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'hook-journal') + stateDirectory: join(root, 'hook-journal') }) const published: string[] = [] const subscribers = new AgentSessionSubscribers({ @@ -223,7 +224,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'unread-journal') + stateDirectory: join(root, 'unread-journal') }) const statusFeed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ @@ -288,7 +289,7 @@ describe('AgentSessionSubscribers', () => { agent: 'claude', providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } }, - journalDir: join(root, 'background-journal') + stateDirectory: join(root, 'background-journal') }) const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] @@ -337,7 +338,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'activity-journal') + stateDirectory: join(root, 'activity-journal') }) const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] @@ -389,7 +390,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir + stateDirectory: journalDir }) // A row admitted before identity bounding: its removal id alone exceeds // the outbound cap, so no catch-up batch can ever carry it. @@ -422,15 +423,15 @@ describe('AgentSessionSubscribers', () => { // Staged straight into the session database, exactly as a previous writer // would have committed them. await seeded.close() - const opened = openJournalDatabase(journalDatabaseFile(journalDir)) + const opened = openTestJournalHostDatabase(journalDir) try { opened.db.exec('BEGIN IMMEDIATE') for (const row of rows) { - insertJournalRow(opened.db, SESSION, row) + insertTestJournalRow(opened.db, SESSION, row) } opened.db.exec('COMMIT') } finally { - opened.db.close() + opened.close() } const journal = await journals.open({ identity: { @@ -440,7 +441,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir + stateDirectory: journalDir }) const subscribers = new AgentSessionSubscribers() 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 4594c8c38bdf..586f8c826300 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 @@ -39,6 +39,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -76,7 +77,7 @@ function openHost( host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index 9fddf6c7b34a..c9cdaa997b13 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -8,7 +8,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { journalRecordsThreadGoalChange, @@ -38,7 +38,7 @@ afterEach(async () => { async function openJournal(): Promise { root ??= await mkdtemp(join(tmpdir(), 'orca-thread-goal-')) - return journals.open({ identity: IDENTITY, journalDir: root }) + return journals.open({ identity: IDENTITY, stateDirectory: root }) } const GOAL: AgentJournalThreadGoal = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 302f6dd812fc..f5d378d8ce86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -30,7 +30,7 @@ afterEach(async () => { describe('performCancel', () => { it('acknowledges only the request and leaves the running lifecycle row intact', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const lifecycleIdentity = { provider: 'legacy' as const, agent: 'codex' as const, @@ -78,7 +78,7 @@ describe('performCancel', () => { it('hands the adapter a live-turn read of the published journal', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-live-turn-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const lifecycleIdentity = { provider: 'legacy' as const, agent: 'codex' as const, @@ -131,7 +131,7 @@ describe('performCancel', () => { it('keeps the running lifecycle when cancellation cannot be confirmed', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) await journal.appendItem( { provider: 'legacy', @@ -178,7 +178,7 @@ describe('performCancel', () => { it('stops background tasks without interrupting the foreground turn or writing a row', async () => { root = await mkdtemp(join(tmpdir(), 'orca-background-task-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { @@ -210,7 +210,7 @@ describe('performCancel', () => { it('routes one background task id without interrupting the foreground turn or writing a row', async () => { root = await mkdtemp(join(tmpdir(), 'orca-background-task-targeted-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 0ce275969665..13aa9d72aea0 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -21,6 +21,7 @@ import { type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { @@ -35,6 +36,11 @@ import { structuredAgentSessionHandoverOrigin, type StructuredAgentSessionCommandHandoverContext } from './structured-agent-session-command-turn' +import { + classifyJournalOpenFailure, + isJournalWrittenByNewerOrca, + journalOpenRefusal +} from '../agent-session-journal/journal-open-failure' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' export { performCancel } from './structured-agent-session-turns-cancel' @@ -137,7 +143,17 @@ export async function performSend( } try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) - } catch { + } catch (error) { + // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So + // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the + // journal already classified (a copy that did not verify). + if ( + isAgentSessionRefusalError(error) || + classifyJournalOpenFailure(error) === 'journalCorrupt' || + isJournalWrittenByNewerOrca(error) + ) { + return { ok: false, refusal: journalOpenRefusal(error) } + } return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') } return { 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 957ac2f275db..a49d411c9cf8 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 @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -84,7 +85,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${claude.connections.length + 1}`, now: () => NOW 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 05c57a5941c7..da3072272576 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 @@ -35,7 +35,6 @@ import { AgentSessionRecordStore } from '../../runtime/agent-session-record-stor import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -47,6 +46,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -141,7 +141,7 @@ function openHost(overrides: Partial = {}): void setOption: vi.fn(), supportsCreate: () => true } as unknown as StructuredAgentSessionAdapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', now: () => NOW, @@ -207,7 +207,7 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< ? { kind: 'codex', threadId: THREAD } : { kind: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } }, - journalDir: journalDirectoryFor(root, { workspaceId: LOCATION.workspaceId, sessionId: SESSION }) + database: openTestJournalHostDatabase(root) }) await journal.appendItem( provider === 'codex' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index cb9f4285d73b..24ae62a872bb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -13,12 +13,13 @@ import { AGENT_JOURNAL_THREAD_SCOPE, AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + insertTestJournalRow +} from '../agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from './agent-session-history-page' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -39,7 +40,7 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: root + stateDirectory: root }) for (let ordinal = 1; ordinal <= 20; ordinal += 1) { await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { @@ -173,15 +174,15 @@ async function reopenWithOversizedRemoval(afterSequence: number): Promise { host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: generationRoot(), + journalDatabase: openTestJournalHostDatabase(generationRoot()), claimKeyId: 'key', now: () => clock, mintSpawnToken: () => `spawn-${acquisitions}`, diff --git a/src/main/runtime/client-hosted-page-reconciliation-window.test.ts b/src/main/runtime/client-hosted-page-reconciliation-window.test.ts index 7aa4562f986f..b52335dadf6e 100644 --- a/src/main/runtime/client-hosted-page-reconciliation-window.test.ts +++ b/src/main/runtime/client-hosted-page-reconciliation-window.test.ts @@ -142,10 +142,11 @@ describe('session-tabs projection census', () => { expect(source).toContain('this.clientSessionTabSelections.project(removed,') }) - it('keeps the unreconciled flag out of every other runtime publication site', () => { + it('keeps the hold flags out of every other runtime publication site', () => { const source = readOrcaRuntimeSourceFamily() expect(source).not.toContain('clientHostedPagesUnreconciled') + expect(source).not.toContain('agentSessionsUnverifiable') }) }) diff --git a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts index 68101c809609..055e64c4622f 100644 --- a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts @@ -40,7 +40,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW } for (const id of ids) { const projected = this.clientSessionTabSelections.activate( - this.withClientHostedPagesHold(snapshot, id), + this.withSessionTabsHolds(snapshot, id), id, activeTabId ) @@ -52,7 +52,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW } else if (clientNavigationId) { // Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner. callerSnapshot = this.clientSessionTabSelections.activate( - this.withClientHostedPagesHold(snapshot, clientNavigationId), + this.withSessionTabsHolds(snapshot, clientNavigationId), clientNavigationId, activeTabId ) diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 73d0e44499fd..a4d010454756 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -8,6 +8,7 @@ import type { } from '../../shared/runtime-types' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' +import { holdAgentSessionInventory } from './structured-agent-session-inventory-hold' import type { Tab } from '../../shared/tab-types' import { resolveTerminalCloseTarget, @@ -294,26 +295,24 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu /** * Answers one client's session-tabs question: whether this runtime has taken back *that* client's - * client-hosted pages yet, then that client's own tab selection. + * client-hosted pages yet and can say which chats exist, then that client's own tab selection. * - * The hold is decided here and nowhere else, and it is set or cleared rather than only set, so a - * frame built for one client can never carry another client's answer. + * The holds are decided here and nowhere else, and each is set or cleared rather than only set, + * so a frame built for one client can never carry another client's, or an earlier, answer. */ protected projectMobileSessionTabsForClient( result: RuntimeMobileSessionTabsResult, clientNavigationId?: string ): RuntimeMobileSessionTabsResult { return this.clientSessionTabSelections.project( - this.withClientHostedPagesHold(result, clientNavigationId), + this.withSessionTabsHolds(result, clientNavigationId), clientNavigationId ) } - protected withClientHostedPagesHold( - result: RuntimeMobileSessionTabsResult, - clientNavigationId: string | undefined - ): RuntimeMobileSessionTabsResult { - return this.clientHostedPageReconciliation.holdFor(result, clientNavigationId, Date.now()) + protected withSessionTabsHolds(result: RuntimeMobileSessionTabsResult, navigationId?: string) { + const held = this.clientHostedPageReconciliation.holdFor(result, navigationId, Date.now()) + return holdAgentSessionInventory(held, this.structuredAgentSessionInventoryUnverifiable) } protected async refreshMobileSessionPtyRecords( diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index e2f1bd34d45a..c0d4c596ffae 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -19,6 +19,7 @@ import { } from './structured-agent-account-home' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' +import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -235,10 +236,19 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR restoreStructuredAgentSessionTabs(): Promise { this.structuredAgentSessionTabRestorePromise ??= - this.restoreStructuredAgentSessionTabsOnce().catch((error) => { - this.structuredAgentSessionTabRestorePromise = null - throw error - }) + this.restoreStructuredAgentSessionTabsOnce().then( + () => { + // Only a host's answer is final: without one, the next caller restores again, so a journal + // that opens later republishes the chats. + if (this.structuredAgentSessionInventoryUnverifiable) { + this.structuredAgentSessionTabRestorePromise = null + } + }, + (error) => { + this.structuredAgentSessionTabRestorePromise = null + throw error + } + ) return this.structuredAgentSessionTabRestorePromise } @@ -256,7 +266,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR return } // Durable agent records must exist before daemon inventory can be reconciled against them. - await this.ensureStructuredAgentSessionHost() + // A refused host is no host: startup goes on, and only structured requests are refused. + await ensureStructuredAgentSessionHostUnlessRefused(() => + this.ensureStructuredAgentSessionHost() + ) await this.refreshMobileSessionPtyRecords() await getStructuredAgentSessionHost()?.reconcileRestartLeases() } 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 f22a09bac5e4..8d29a0d360b9 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 @@ -86,6 +86,14 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu notify: false }) } + const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable + // No host means no one can say which chats exist; with none on disk, empty is the answer. + this.structuredAgentSessionInventoryUnverifiable = + !host && this.hasPersistedStructuredAgentSessionStore() + // This restore published quietly; subscribers still hold the frames that said "cannot tell". + if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { + this.notifyMobileSessionTabSnapshots() + } } async publishStructuredAgentSessionTab(input: { diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index d724207f9210..f033cb456ebc 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -121,6 +121,9 @@ export class OrcaRuntimeWithRuntimeId { protected structuredAgentSessionTabRestorePromise: Promise | null = null + // Whether the last tab restore ran with chats on disk but no host to list them. + protected structuredAgentSessionInventoryUnverifiable = false + protected structuredAgentSessionStartupRestorePromise: Promise | null = null protected mobileSessionTabsChangeSequence = 0 diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 624557c1f7f4..fa9106dc2875 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -20,6 +20,7 @@ import { agentSessionRefusalError, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' +import { readAgentSessionErrorRefusal } from '../../../shared/agent-session-write-failure' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -356,6 +357,34 @@ describe('thrown agent-session refusals', () => { }) }) + // The shape every client test of a thrown refusal feeds in, pinned to what this produces. + it("sends a journal refusal's reason where a client reads it", () => { + const response = mapRuntimeError( + 'req_1', + meta, + agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalCorrupt' }, + 'Unable to load this chat.' + ) + ) + const wire = JSON.parse(JSON.stringify(response.error)) + expect(wire).toEqual({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + }) + expect(readAgentSessionErrorRefusal(wire)).toEqual({ + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + }) + it('exposes no code property another passthrough could claim', () => { expect( 'code' in agentSessionRefusalError('agent_session_conflict', { reason: 'claimConflicted' }) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index d943775ef3ba..a240c0c39191 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -25,6 +25,8 @@ import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams } from './ai-vault' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-host-refusal' import { configureAiVaultSessionSources, listAiVaultSessions, @@ -221,6 +223,65 @@ describe('aiVault.prepareSessionResume', () => { }) }) +// Session history and terminal resume are not chats: a process whose chats are refused still +// serves them, while any other host failure still fails the request. +describe('aiVault methods without a structured host', () => { + const refusal = agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalCorrupt' }, + 'Unable to load this chat.' + ) + + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessionsInWorker.mockReset() + scanAiVaultSessionsInWorker.mockResolvedValue(makeResult()) + recordStructuredAgentSessionHostInstallRefusal(refusal) + }) + + afterEach(() => { + recordStructuredAgentSessionHostInstallRefusal(null) + resetAiVaultSessionListCacheForTests() + }) + + function refusedDispatcher(installError: Error): RpcDispatcher { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the two aiVault handlers read only these runtime members. + const runtime = { + getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn().mockRejectedValue(installError), + listAiVaultSessions: (args?: Parameters[0]) => + listAiVaultSessions(args), + prepareAiVaultSessionResume: vi.fn().mockResolvedValue({ useRealCodexHome: true }) + } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + } + + it('lists and prepares a resume while chats are refused', async () => { + const dispatcher = refusedDispatcher(refusal) + + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + ).resolves.toMatchObject({ ok: true, result: makeResult() }) + await expect( + dispatcher.dispatch( + makeRequest('aiVault.prepareSessionResume', { + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed' + }) + ) + ).resolves.toMatchObject({ ok: true, result: { useRealCodexHome: true } }) + }) + + it('still fails on a host error that refuses nothing', async () => { + const dispatcher = refusedDispatcher(new Error('the record store would not open')) + + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + ).resolves.toMatchObject({ ok: false }) + }) +}) + describe('aiVault.listSessions handler + shared cache', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index b6a2165939a0..b4169145902b 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -17,6 +17,7 @@ import { assertLegacyAiVaultResumeAllowed, projectStructuredAiVaultSessions } from '../../../ai-vault/structured-session-ownership' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-host-refusal' import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, @@ -66,7 +67,9 @@ export const AI_VAULT_METHODS = [ name: 'aiVault.listSessions', params: AiVaultListSessionsParams, handler: async (params, { runtime, clientKind, clientCapabilities }) => { - await runtime.ensureStructuredAgentSessionHost() + await ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ) let result try { result = await runtime.listAiVaultSessions({ @@ -107,7 +110,9 @@ export const AI_VAULT_METHODS = [ // client-provided runtime/SSH stamp escape that host boundary. executionHostId: LOCAL_EXECUTION_HOST_ID } - await runtime.ensureStructuredAgentSessionHost() + await ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ) assertLegacyAiVaultResumeAllowed(args) return runtime.prepareAiVaultSessionResume(args) } 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 a2b049e4190e..4748c7dd5a67 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 @@ -12,6 +12,7 @@ import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -183,7 +184,7 @@ describe('committed adopting create RPC replay', () => { host = new StructuredAgentSessionHost({ store, adapter: sessionAdapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1' }) setStructuredAgentSessionHost(host) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index 63d24030c70b..9cbad804cd50 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -6,13 +6,7 @@ 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 { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' -import { openJournalDatabase } from '../../../native-chat/agent-session-journal/journal-database' import { - journalDatabaseFile, - journalDirectoryFor -} from '../../../native-chat/agent-session-journal/journal-paths' -import { - HOST_TEST_LOCATION, hostTestAttachParams, hostTestMessage, hostTestOperationId @@ -34,6 +28,10 @@ import { RpcDispatcher } from '../dispatcher' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' import { discardStructuredWorkerSession } from './orchestration-structured-worker-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { + openTestJournalHostDatabase, + updateTestJournalRowJson +} from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const CLIENT = { clientId: 'device-1', @@ -313,17 +311,8 @@ describe('the accessor', () => { it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() - const directory = journalDirectoryFor(rig.root, { - workspaceId: HOST_TEST_LOCATION.workspaceId, - sessionId: SESSION - }) // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds. - const opened = openJournalDatabase(journalDatabaseFile(directory)) - try { - opened.db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) - } finally { - opened.db.close() - } + updateTestJournalRowJson(openTestJournalHostDatabase(rig.root).db, SESSION, 2, '}{') await rig.restart() setStructuredAgentSessionHost(rig.host) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index e4ff861d1ac7..0bed563af38b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -13,6 +13,7 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' +import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' import { supportsStructuredAgentSessionCapability, supportsStructuredAgentSessions @@ -36,13 +37,7 @@ export function requireStructuredCapability(ctx: RpcContext): void { export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost { requireStructuredCapability(ctx) - const host = getStructuredAgentSessionHost() - if (!host) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostDisabled' - }) - } - return host + return requireHostOrRefusal() } /** @@ -73,13 +68,22 @@ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSe reason: 'clientCapabilityMissing' }) } + return requireHostOrRefusal() +} + +/** + * The host, or why there is none. A process whose journal would not open says so under every + * getter — cleanup included: nothing here can stop a child it never started. + */ +function requireHostOrRefusal(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() - if (!host) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostDisabled' - }) + if (host) { + return host } - return host + throw ( + structuredAgentSessionHostRefusal() ?? + agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostDisabled' }) + ) } /** Builds the host for a call that may be the first this process sees. Every session is addressed 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 140be5e1c39b..aa2b2da15486 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 @@ -26,6 +26,7 @@ import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const CONNECTION = 'connection-1' const CLIENT = { @@ -93,7 +94,7 @@ beforeEach(async () => { answerPrompt: async () => undefined, setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW 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 f76b3ae6cb74..9bd7b1fade0e 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 @@ -33,6 +33,7 @@ import { SESSION_TAB_METHODS } from './session-tabs' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -93,7 +94,7 @@ async function openHost(): Promise { host = new StructuredAgentSessionHost({ store, adapter: providerAdapter(), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, mintSpawnToken: () => `spawn-${acquisitions}` diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index f4baa90da20d..875f77a754b5 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -13,7 +13,7 @@ import type { } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' @@ -67,7 +67,7 @@ async function openJournal(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) } diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 5b081d1e5dfd..50e974f1eb5e 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' @@ -67,7 +67,7 @@ async function awaitingApproval() { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { ...IDENTITY, ordinal: 1 }, diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts new file mode 100644 index 000000000000..689ac3df969c --- /dev/null +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -0,0 +1,397 @@ +// A process whose chat journal will not open refuses every chat, leaves the file alone, and +// otherwise starts as usual: terminals, tabs and session history go on without a host. + +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, unlink, 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 { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' +import { + JournalHostDatabase, + journalDatabasePath +} from '../native-chat/agent-session-journal/journal-host-database' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import Database from '../sqlite/sync-database' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' +import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' +import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' +import type { RpcContext } from './rpc/core' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let root: string + +// An in-process caller: the same build as the host, so the gate asks it for no capability. +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. +const IN_PROCESS = {} as RpcContext + +function install(): ReturnType { + return ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) +} + +/** The refusal as the gate throws it for every structured request. */ +function gateRefusal(): { reason: unknown; message: string } { + try { + requireStructuredCleanupHost(IN_PROCESS) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { reason: error.refusal.details?.reason, message: error.refusal.message } + } + throw error + } + throw new Error('the gate admitted the request') +} + +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-host-refusal-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('a process whose journal will not open', () => { + // T-corrupt-open: the error surfaces, every chat says it cannot be loaded, and nothing is + // renamed, deleted or rebuilt. + it('refuses every chat and leaves a damaged file exactly as it is', async () => { + const path = journalDatabasePath(root) + await writeFile(path, 'not a database '.repeat(512)) + const before = await digest(path) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(install()).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + expect(await digest(path)).toBe(before) + expect(existsSync(`${path}-wal`)).toBe(false) + expect(existsSync(`${path}-shm`)).toBe(false) + + // Once a person moves the file aside, the next request installs. + await unlink(path) + await expect(install()).resolves.toBeDefined() + expect(gateRefusal).toThrow('the gate admitted the request') + }) + + // A database from an unreleased build of this change: never migrated or renamed, so it reads as + // damage, and the log says where it is and what to do. + it('refuses a database an unreleased build wrote as unusable, and says to move it aside', async () => { + const path = journalDatabasePath(root) + const earlier = new Database(path) + earlier.exec('CREATE TABLE journal_rows (id INTEGER PRIMARY KEY, row_json TEXT NOT NULL)') + earlier.pragma('user_version = 2') + earlier.close() + const before = await digest(path) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect(install()).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + } + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + expect(warn).toHaveBeenCalledOnce() + 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(await digest(path)).toBe(before) + }) + + // T5: a newer build's database is not refused: the host installs over it read-only, so chats + // stay visible, and the file is left byte-identical. + it('installs over a database a newer Orca wrote, and leaves it byte-identical', async () => { + const path = journalDatabasePath(root) + const seeded = new Database(path) + seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + seeded.close() + const before = await digest(path) + + await expect(install()).resolves.toBeDefined() + expect(gateRefusal).toThrow('the gate admitted the request') + await stopStructuredAgentSessionRuntime() + expect(await digest(path)).toBe(before) + }) +}) + +describe('logging a journal that will not open', () => { + const OPEN_FAILED = '[structured-agent-session] opening the chat journal database failed' + + async function writeJunkJournal(): Promise { + const path = journalDatabasePath(root) + await rm(`${path}-wal`, { force: true }) + await rm(`${path}-shm`, { force: true }) + await writeFile(path, 'not a database '.repeat(512)) + } + + // Every chat request retries the open; the same failure each time is one log, not one per request. + it('logs a repeated failure once, with its stack, and again after an open succeeds', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const openFailureLogs = () => warn.mock.calls.filter(([message]) => message === OPEN_FAILED) + await install() + await stopStructuredAgentSessionRuntime() + await writeJunkJournal() + + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + } + expect(openFailureLogs()).toHaveLength(1) + expect(openFailureLogs()[0]?.[1]).toBeInstanceOf(Error) + expect(openFailureLogs()[0]?.[1]).toHaveProperty('stack', expect.stringContaining('\n')) + + await unlink(journalDatabasePath(root)) + await install() + await stopStructuredAgentSessionRuntime() + await writeJunkJournal() + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + expect(openFailureLogs()).toHaveLength(2) + }) +}) + +// A refused host is a no-host state for startup: the app restores terminals and tabs as usual, +// and only structured requests are refused. +describe('startup and other non-chat work without a structured host', () => { + function startupRuntime(ensureHost: () => Promise = install) { + const runtime = new OrcaRuntimeService() + const refreshPtyRecords = vi.fn(async () => new Set()) + const hydrateTabs = vi.fn(() => new Set()) + // 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> + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = ensureHost + internal.refreshMobileSessionPtyRecords = refreshPtyRecords + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrateTabs + return { runtime, refreshPtyRecords, hydrateTabs } + } + + async function expectStartupWithoutHost(runtime: OrcaRuntimeService): Promise { + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + // What `session.tabs.list` awaits before it answers a paired client. + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + expect(getStructuredAgentSessionHost()).toBeNull() + } + + async function writeJunkJournal(): Promise { + await writeFile(journalDatabasePath(root), 'not a database '.repeat(512)) + } + + it('goes ahead when its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords, hydrateTabs } = startupRuntime() + + await expectStartupWithoutHost(runtime) + + expect(refreshPtyRecords).toHaveBeenCalledOnce() + expect(hydrateTabs).toHaveBeenCalledWith('workspace-1', { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + }) + + // The inventory must say "cannot tell", never "no chats": a client culls what an answer omits, + // and the desktop then saves its chat tabs away. + describe('the session-tabs inventory', () => { + /** The worktree's frame as the renderer's own graph publication leaves it: no chat rows. */ + const WORKTREE_FRAME: RuntimeMobileSessionTabsResult = { + worktree: 'workspace-1', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + + function publishWorktreeFrame(runtime: OrcaRuntimeService): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected store, called as its graph publication does. + const internal = runtime as unknown as { + storeMobileSessionSnapshot(worktreeId: string, snapshot: unknown): unknown + } + internal.storeMobileSessionSnapshot('workspace-1', WORKTREE_FRAME) + } + + async function listInventory(runtime: OrcaRuntimeService) { + await runtime.restoreStructuredAgentSessionTabs() + return runtime.listAllMobileSessionTabs() + } + + it('marks chats unverifiable when its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toEqual([ + expect.objectContaining({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + ]) + }) + + // A tap's reply goes straight back to the tapping client, not through the list. + it.each(['caller', 'clients'])( + 'marks a %s navigation reply unverifiable while its journal will not open', + async (navigation) => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + await listInventory(runtime) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected navigation step, called as `session.tabs.activate` does. + const internal = runtime as unknown as { + applyMobileSessionTabNavigation( + snapshot: RuntimeMobileSessionTabsResult, + activeTabId: string, + navigation: RuntimeNavigationTarget, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult + } + + const reply = internal.applyMobileSessionTabNavigation( + WORKTREE_FRAME, + 'agent-session:claude-1', + navigation, + 'phone-1' + ) + + expect(reply).toMatchObject({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + } + ) + + it('lists no chats as a real answer once there is a host', async () => { + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toHaveLength(1) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(getStructuredAgentSessionHost()).not.toBeNull() + }) + + // Subscribers still hold the frames that said "cannot tell"; the restore that can tell pushes. + it('pushes the chats without the mark once a later list has a host', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + expect((await listInventory(runtime))[0]).toMatchObject({ agentSessionsUnverifiable: true }) + const pushed: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) + + // Once a person moves the file aside, a chat request installs the host. + await unlink(journalDatabasePath(root)) + const host = await install() + vi.spyOn(host, 'listSessionTabs').mockReturnValue([ + { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' } + ]) + vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) + const frames = await listInventory(runtime) + unsubscribe() + + const chats = [expect.objectContaining({ type: 'agent-session', sessionId: 'claude-1' })] + expect(frames).toEqual([expect.objectContaining({ tabs: chats })]) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(pushed.at(-1)?.tabs).toEqual(chats) + expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') + }) + }) + + it('lets a terminal resume command through while its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // The check `terminal.send` and `session.tabs.createTerminal` run before typing a resume. + await expect( + assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { + await install() + }) + ).resolves.toBeUndefined() + await expect( + assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { + throw new Error('the record store would not open') + }) + ).rejects.toThrow('the record store would not open') + 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') + }) + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).rejects.toThrow( + 'the record store would not open' + ) + expect(refreshPtyRecords).not.toHaveBeenCalled() + }) +}) + +describe('stopping', () => { + it('keeps the connection until a journal close that failed is retried and goes through', async () => { + await install() + const close = JournalHostDatabase.prototype.close + let connectionClose: ReturnType | null = null + const journalClose = vi + .spyOn(JournalHostDatabase.prototype, 'close') + .mockImplementation(function (this: JournalHostDatabase) { + // The connection's first close fails, as a native close can before it completes. + connectionClose ??= vi.spyOn(this.db, 'close').mockImplementationOnce(() => { + throw new Error('unable to close due to unfinalized statements') + }) + close.call(this) + }) + const journal = () => journalClose.mock.contexts[0] + + await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow('unable to close') + expect(journal()).toHaveProperty('isClosed', false) + + await stopStructuredAgentSessionRuntime() + expect(connectionClose).toHaveBeenCalledTimes(2) + expect(journal()).toHaveProperty('isClosed', true) + }) +}) diff --git a/src/main/runtime/structured-agent-session-host-refusal.ts b/src/main/runtime/structured-agent-session-host-refusal.ts new file mode 100644 index 000000000000..30465c0fb129 --- /dev/null +++ b/src/main/runtime/structured-agent-session-host-refusal.ts @@ -0,0 +1,47 @@ +// Why structured chats are refused in this process: its chat journal would not open at install +// (damaged or unavailable). Everything else goes on without a host. + +import { + isAgentSessionRefusalError, + type AgentSessionRefusalError +} from '../../shared/agent-session-wire-refusals' + +let installRefusal: AgentSessionRefusalError | null = null + +/** Why structured chats are refused in this process, or null when nothing refuses them. */ +export function structuredAgentSessionHostRefusal(): AgentSessionRefusalError | null { + return installRefusal +} + +/** Whether `error` is the refusal structured requests are getting right now. */ +function isStructuredAgentSessionHostRefusal(error: unknown): boolean { + const refusal = installRefusal + return ( + refusal !== null && + isAgentSessionRefusalError(error) && + error.refusal.code === refusal.refusal.code && + error.refusal.message === refusal.refusal.message + ) +} + +/** For work that goes on without chats: the refusal chats are getting leaves this process with no + * host, and any other install failure still throws. */ +export async function ensureStructuredAgentSessionHostUnlessRefused( + ensureHost: () => Promise +): Promise { + try { + await ensureHost() + } catch (error) { + if (!isStructuredAgentSessionHostRefusal(error)) { + throw error + } + } +} + +/** Set when the journal database would not open; cleared by a later install. The install retries + * on the next call, so a refusal that can clear does. */ +export function recordStructuredAgentSessionHostInstallRefusal( + refusal: AgentSessionRefusalError | null +): void { + installRefusal = refusal +} diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts new file mode 100644 index 000000000000..53720659ec41 --- /dev/null +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -0,0 +1,68 @@ +// An install that fails after it opened the chat journal closes that connection, so the next +// install does not leave a second one open in the same process. + +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 { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type * as ModelCatalogWiring from './structured-agent-model-catalog-wiring' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const mocks = vi.hoisted(() => ({ failWiring: vi.fn(() => false) })) + +// The last step before the host is built: a throw here comes after both stores opened. +vi.mock('./structured-agent-model-catalog-wiring', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + modelCatalogHostDeps: async (input: Parameters[0]) => { + if (mocks.failWiring()) { + throw new Error('model catalog wiring failed') + } + return actual.modelCatalogHostDeps(input) + } + } +}) + +let root: string + +function install(): ReturnType { + return ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-install-failure-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('an install that fails after opening the chat journal', () => { + it('closes the journal connection it opened, and the next install opens its own', async () => { + const open = vi.spyOn(JournalHostDatabase, 'open') + mocks.failWiring.mockReturnValueOnce(true) + + await expect(install()).rejects.toThrow('model catalog wiring failed') + const failed = 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) + }) +}) diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index 29dbdf593023..b4d0399d10da 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -9,8 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { CodexStructuredSessionAdapter } from '../codex/codex-structured-session-adapter' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import { ensureStructuredAgentSessionHost, stopStructuredAgentSessionRuntime @@ -123,7 +122,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(harness.root, identity) + stateDirectory: harness.root }) expect(reopened.snapshot().items.map(textOf)).toContain('Buffered while the journal opens.') expect( diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 1b7136a996df..cd8939b93888 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -30,10 +30,9 @@ import type { } from '../../shared/agent-session-wire' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { importLegacyTranscriptIntoJournal } from '../native-chat/agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' @@ -410,7 +409,7 @@ describe('a structured codex session over agentSession.*', () => { } const journal = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + stateDirectory: root }) const rollout = join(root, 'legacy-rollout.jsonl') await writeFile( @@ -428,6 +427,8 @@ describe('a structured codex session over agentSession.*', () => { fence: 0, options: { filePath: rollout } }) + // The previous process exits, closing its database. + await journals.closeAll() const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( 'agentSession.create', @@ -827,7 +828,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + stateDirectory: root }) expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') expect( diff --git a/src/main/runtime/structured-agent-session-inventory-hold.ts b/src/main/runtime/structured-agent-session-inventory-hold.ts new file mode 100644 index 000000000000..9ab1e59aeb14 --- /dev/null +++ b/src/main/runtime/structured-agent-session-inventory-hold.ts @@ -0,0 +1,20 @@ +/** + * Answers whether a snapshot bound for a client may be read as this runtime's chat inventory. + * + * Sets *and* clears, like the client-hosted page hold beside it, because a frame is built once + * and may already carry an answer from before the last tab restore. + */ +export function holdAgentSessionInventory( + result: T, + unverifiable: boolean +): T { + if (unverifiable === (result.agentSessionsUnverifiable === true)) { + return result + } + if (unverifiable) { + return { ...result, agentSessionsUnverifiable: true as const } + } + const { agentSessionsUnverifiable: _held, ...released } = result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the optional hold field was removed, so the rest is still a T. + return released as T +} diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts new file mode 100644 index 000000000000..6972a18a7960 --- /dev/null +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -0,0 +1,39 @@ +// Opening the chat journal database for the host install. + +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' + +// Every chat request retries a failed open, so each distinct failure is logged once, with its stack. +let lastLoggedOpenFailure: string | null = null + +function logOpenFailureOnce(error: unknown): void { + const failure = + error instanceof Error + ? `${'code' in error ? String(error.code) : ''}:${error.message}` + : String(error) + if (failure === lastLoggedOpenFailure) { + return + } + lastLoggedOpenFailure = failure + console.warn('[structured-agent-session] opening the chat journal database failed', error) +} + +/** The journal database. A refusal is recorded for the gate and thrown to the caller; the next + * install tries again. */ +export function openStructuredAgentSessionJournalDatabase( + stateDirectory: string +): JournalHostDatabase { + try { + const opened = JournalHostDatabase.open(stateDirectory) + recordStructuredAgentSessionHostInstallRefusal(null) + lastLoggedOpenFailure = null + return opened + } catch (error) { + logOpenFailureOnce(error) + // Nothing is renamed, deleted or rebuilt: the file is left exactly as it is. + const refusal = journalOpenRefusalError(error) + recordStructuredAgentSessionHostInstallRefusal(refusal) + throw refusal + } +} diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 90f4ff92c4bc..47d93e028307 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -6,10 +6,13 @@ import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' export type InstalledRuntime = { host: StructuredAgentSessionHost adapter: { closeAll(): Promise } + /** Closed last, once every conversation has drained into it. */ + journalDatabase: JournalHostDatabase /** Resolves after every observed adapter exit has published, and every * recovery callback it raised has settled. */ waitForRecovery: () => Promise @@ -65,10 +68,13 @@ export async function tearDownRuntime( } catch (error) { failures.push(error) } + if (failures.length === 0) { + // After every sink drained and every conversation settled: nothing writes after this. + installed.journalDatabase.close() + return + } if (failures.length === 1) { throw failures[0] } - if (failures.length > 1) { - throw new AggregateError(failures, 'structured agent-session runtime teardown failed') - } + throw new AggregateError(failures, 'structured agent-session runtime teardown failed') } diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 9f4f4e2474de..3e2a1f14b869 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -2,9 +2,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { agentSessionJournalCloseRetries } from '../native-chat/agent-session-journal/journal-close-retry' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import type { AgentSessionClaimStatus, AgentSessionExecutionLocation, @@ -263,69 +260,3 @@ describe('structured agent-session runtime install', () => { } }) }) - -// A stop whose teardown fails must not forget the runtime it was tearing down. -// `installing` is cleared either way so nothing new attaches, but the host keeps -// every journal whose close rejected, and this module slot is the only handle -// onto that host once it is gone. -describe('a teardown that fails is retried by the next stop', () => { - const JOURNAL_IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-teardown-retry', - workspaceId: 'ws-1', - hostId: HOST_ID, - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - } - const journals = createTrackedJournalOpener() - let directory: string | null = null - - afterEach(async () => { - await agentSessionJournalCloseRetries.retryAll() - await journals.closeAll() - await stopStructuredAgentSessionRuntime().catch(() => undefined) - if (directory) { - await rm(directory, { recursive: true, force: true }) - directory = null - } - }) - - it('reports the failure, then releases the handle on the following stop', async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) - await ensureStructuredAgentSessionHost({ - stateDirectory: directory, - hostId: HOST_ID, - claimKeyId: 'key-1', - resolveWorkspacePath: async () => directory!, - resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) - }) - - const journalDir = join(directory, 'stubborn-journal') - const real = await journals.open({ identity: JOURNAL_IDENTITY, journalDir }) - let closeFailures = 2 - const flaky = new Proxy(real, { - get(target, property, receiver) { - if (property !== 'close') { - // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. - return Reflect.get(target, property, receiver) - } - return async () => { - if (closeFailures > 0) { - closeFailures -= 1 - throw new Error('close rejected') - } - await target.close() - } - } - }) - await agentSessionJournalCloseRetries.closeOrRetain(flaky) - - // The host's teardown runs the registry retry, so this stop surfaces it. - await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([journalDir]) - - // The retained runtime is what makes this a retry rather than a no-op. - await stopStructuredAgentSessionRuntime() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - }) -}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 10db8ba3bda4..24735c721cc2 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -1,11 +1,14 @@ // Where the structured agent-session wire becomes a live host on this runtime. // // Built on the first `agentSession.*` call rather than at startup: the record -// store and the journals live under the profile's user-data path, which is not +// store and the journal live under the profile's user-data path, which is not // final until Electron is ready, and a runtime that never serves a structured // session should not pay for a store it will never read. The slot the RPC layer // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. +// +// A process whose journal will not open installs none and answers every +// structured request with the refusal that says why. import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' @@ -37,6 +40,8 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' 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 { createStructuredAgentSessionOwnerProbe, @@ -53,8 +58,7 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -/** Sibling of the journal tree rather than inside it: one file adjudicates every - * session's lease, while a journal is per session. */ +/** Beside the journal database: one file adjudicates every session's lease. */ const RECORD_STORE_DIR_NAME = 'agent-sessions' export function hasPersistedStructuredAgentSessionStore( @@ -66,7 +70,7 @@ export function hasPersistedStructuredAgentSessionStore( } export type StructuredAgentSessionRuntimeDeps = { - /** Host state root. The record store and the journal tree both hang off it. */ + /** Host state root. The record store and the journal database both hang off it. */ stateDirectory: string /** Execution host this runtime *is*. A record pinned elsewhere is not ours to * probe and not ours to spawn for. */ @@ -119,9 +123,8 @@ export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED = /** * Runtimes whose teardown did not finish. `installing` is cleared regardless so * nothing new attaches, but dropping the runtime as well would strand every - * journal the host retained for a retry: `tearDownStructuredAgentSessionHost` - * deliberately keeps a failed close indexed, and only a later stop through this - * same runtime can reach those entries again. + * conversation the host kept indexed for a retry — and the journal connection, + * which closes only once they are settled — so a later stop retries them here. */ const pendingTeardown = new Set() @@ -151,7 +154,7 @@ export async function waitForStructuredAgentSessionRecovery(): Promise { * test isolation take the same path, so neither can leave a live app-server. * * A teardown that fails is RETRIED by the next stop rather than forgotten: the - * host keeps every journal whose close rejected, and this is the only handle + * host keeps every conversation it could not settle, and this is the only handle * onto that host once the module slot is cleared. */ export async function stopStructuredAgentSessionRuntime(options?: { trigger?: AgentSessionResumeTrigger @@ -191,6 +194,20 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { const envResolvers = createStructuredAgentEnvironmentResolvers(deps) const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers const store = await AgentSessionRecordStore.open({ @@ -288,7 +305,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { adapter: new StructuredAgentSessionAdapterRouter({ claude: adapter, codex: adapter }, () => adapter.closeAll() ), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }) diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 100f1e213863..2e143dbb7032 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -224,7 +224,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b // look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) // Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. - // Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). + // Why dev locks too: two processes on one profile corrupt its stores (PR #1326 / #1312); parallel `pnpm dev` needs ORCA_DEV_USER_DATA_PATH per copy. const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) if (bypass) { @@ -236,12 +236,16 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b logStartupDiagnostic('single-instance-lock-result', { acquired: hasLock, bypassed: bypass, - skippedForDev: skip + skippedForE2E: skip }) } if (!hasLock) { // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. - logSingleInstanceLockFailure() + // In dev it is the line `pnpm dev` prints before exiting. + logSingleInstanceLockFailure({ + isDevDesktop: isDev && !state.isServeMode, + userDataPath: app.getPath('userData') + }) // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) return false diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index e023d7895f54..f1b5abfe0a35 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -9,7 +9,8 @@ import { shouldSkipSingleInstanceLock, SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, - SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE + SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, + singleInstanceLockFailureMessage } from './single-instance-lock' type Listener = (...args: unknown[]) => void @@ -117,18 +118,23 @@ describe('SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE', () => { }) describe('shouldSkipSingleInstanceLock', () => { - it('keeps ordinary dev multi-instance behavior but never skips for serve', () => { - expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true) - expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + const E2E = { ORCA_E2E_USER_DATA_DIR: '/tmp/orca-e2e-userdata-1' } + + it('takes the lock for dev desktop, packaged and serve launches alike', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(false) expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: true, env: E2E })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: E2E })).toBe(false) }) - it('lets isolated E2E exercise the production single-instance path', () => { + it('skips only an E2E launch on its own profile that did not ask for the lock', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: E2E })).toBe(true) expect( shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, - env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } + env: { ...E2E, ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } }) ).toBe(false) }) @@ -138,11 +144,27 @@ describe('logSingleInstanceLockFailure', () => { it('emits a production-visible synchronous diagnostic for the early quit path', () => { const write = vi.fn() - logSingleInstanceLockFailure(write) + logSingleInstanceLockFailure({ isDevDesktop: false, userDataPath: '/profile' }, write) expect(write).toHaveBeenCalledWith(2, `${SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE}\n`) expect(write.mock.calls[0]?.[1]).toContain('Electron/macOS single-instance lock failure') }) + + it('tells a second dev launch which profile is taken and how to run another copy', () => { + const write = vi.fn() + const userDataPath = '/Users/dev/Library/Application Support/orca-dev' + + logSingleInstanceLockFailure({ isDevDesktop: true, userDataPath }, write) + + expect(write).toHaveBeenCalledOnce() + const line = String(write.mock.calls[0]?.[1]) + expect(line).toBe(`${singleInstanceLockFailureMessage({ isDevDesktop: true, userDataPath })}\n`) + expect(line.trimEnd()).not.toContain('\n') + expect(line).toContain(`the profile at ${userDataPath}`) + expect(line).toContain('ORCA_DEV_USER_DATA_PATH=') + // A background or serve instance shows no window, so the line claims none. + expect(line).not.toMatch(/focus|window/i) + }) }) describe('shouldBypassSingleInstanceLock', () => { diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 69404b42e806..521b921408e3 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -64,17 +64,37 @@ export function shouldBypassSingleInstanceLock(options: { ) } +// Why only the E2E harness: dev desktops lock like packaged ones, since two on one profile corrupt +// every store; each E2E launch has its own throwaway profile, and a spec opts in to test the lock. export function shouldSkipSingleInstanceLock(options: { env?: NodeJS.ProcessEnv isDev: boolean isServeMode: boolean }): boolean { const env = options.env ?? process.env - return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' + return ( + options.isDev && + !options.isServeMode && + Boolean(env.ORCA_E2E_USER_DATA_DIR) && + env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' + ) +} + +export function singleInstanceLockFailureMessage(options: { + isDevDesktop: boolean + userDataPath: string +}): string { + if (!options.isDevDesktop) { + return SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE + } + return `[single-instance] Another Orca dev instance is already running on the profile at ${options.userDataPath}; exiting this launch after passing it this launch's request. To run another dev copy at the same time, give it its own profile: ORCA_DEV_USER_DATA_PATH= pnpm dev` } -export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { - writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) +export function logSingleInstanceLockFailure( + options: { isDevDesktop: boolean; userDataPath: string }, + write?: StartupDiagnosticSink +): void { + writeStartupDiagnosticLine(singleInstanceLockFailureMessage(options), write) } export function logSingleInstanceLockBypass(write?: StartupDiagnosticSink): void { diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 812197d6e336..14d265256d2d 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -44,6 +44,13 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.historyUnusable', COPY.historyUnusable), historyUnavailable: () => translate('components.native-chat.writeNotice.historyUnavailable', COPY.historyUnavailable), + savedByNewerOrca: () => + translate('components.native-chat.writeNotice.savedByNewerOrca', COPY.savedByNewerOrca), + updateOrcaToKeepUsing: () => + translate( + 'components.native-chat.writeNotice.updateOrcaToKeepUsing', + COPY.updateOrcaToKeepUsing + ), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), recordFailed: () => diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index c42121a2b5c7..8340bb7f028b 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -2,8 +2,13 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../../shared/agent-session-wire' +import { + agentSessionRefusalFailure, + readAgentSessionErrorRefusal +} from '../../../../shared/agent-session-write-failure' import { disposeStructuredAgentSessionSendFailure, + disposeStructuredAgentSessionSendRefusal, disposeStructuredAgentSessionSendResult, type StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' @@ -133,14 +138,25 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { if (args.dispatchGenerationRef.current !== args.dispatchGeneration) { return false } + const input = { + entries: args.outboxRef.current, + entry: args.next, + blockedClientMessageId: args.blockedIdRef.current + } + const thrown = readAgentSessionErrorRefusal(caught) + const refusal = thrown ? agentSessionRefusalFailure(thrown) : undefined args.applyDisposition( - disposeStructuredAgentSessionSendFailure({ - entries: args.outboxRef.current, - entry: args.next, - blockedClientMessageId: args.blockedIdRef.current, - cause: caught, - isDeliveryUnknown: isDesktopDeliveryUnknown - }) + refusal + ? disposeStructuredAgentSessionSendRefusal({ + ...input, + refusal, + createOperationId: args.createOperationId + }) + : disposeStructuredAgentSessionSendFailure({ + ...input, + cause: caught, + isDeliveryUnknown: isDesktopDeliveryUnknown + }) ) return false } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index 683676c99d65..eaee915ac685 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -19,6 +19,7 @@ vi.mock('@/runtime/runtime-host-contact-regained', () => ({ subscribeRuntimeHostContactRegained: mocks.watchHostContact })) +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' type SubscribeAttempt = { @@ -390,6 +391,55 @@ describe('structured agent-session read transport unattached refusals', () => { } }) + // As `mapRuntimeError` sends a thrown refusal (pinned in `rpc/errors.test.ts`): its message is the + // bare code, and its reason rides in data. + function thrownRefusal(details: Record) { + return { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details } } + } + } + const JOURNAL_CORRUPT = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + + it("hands the pane a thrown refusal's reason from the stream and a rejected read", async () => { + vi.useFakeTimers() + try { + const applyError = vi.fn() + const streamed = startWithHydration(async () => undefined, applyError) + await flushPromises() + attempts[0].onError(thrownRefusal(JOURNAL_CORRUPT.details)) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenCalledExactlyOnceWith( + 'agent_session_journal_unreadable', + JOURNAL_CORRUPT + ) + streamed.dispose() + + const rejectedError = vi.fn() + const refused = new RuntimeRpcCallError({ + id: 'req-1', + ok: false, + error: thrownRefusal(JOURNAL_CORRUPT.details) + }) + const rejected = startWithHydration(async () => { + throw refused + }, rejectedError) + await flushPromises() + expect(rejectedError).toHaveBeenCalledExactlyOnceWith( + 'agent_session_journal_unreadable', + JOURNAL_CORRUPT + ) + rejected.dispose() + } finally { + vi.useRealTimers() + } + }) + it('re-opens after a failed open and leaves the error once the conversation reads (P2-04)', async () => { vi.useFakeTimers() try { @@ -397,7 +447,7 @@ describe('structured agent-session read transport unattached refusals', () => { const applyEvent = vi.fn() const transport = startWithHydration(async () => undefined, applyError, applyEvent) await flushPromises() - attempts[0].onError({ code: 'agent_session_journal_unreadable', message: 'disk full' }) + attempts[0].onError({ code: 'runtime_error', message: 'disk full' }) attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) await flushPromises() expect(applyError).toHaveBeenCalledExactlyOnceWith('disk full') diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 3ce99e6fd4b4..f3386affd1af 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -14,6 +14,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' @@ -450,3 +451,86 @@ describe('a send refused while its agent restarted', () => { expect(result.current.blockedClientMessageId).toBeNull() }) }) + +describe('a send the host refused by throwing', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it("keeps the refusal on the message, with the refusal's words and not a bare failure", async () => { + // As `mapRuntimeError` sends a thrown refusal (pinned in `rpc/errors.test.ts`). + mocks.call.mockRejectedValue( + new RuntimeRpcCallError({ + id: 'req-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + + await waitFor(() => + expect(result.current.outbox[0]?.lastFailure).toEqual({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + }) + ) + expect(shownFailure(result.current.outbox[0])).toBe( + 'Unable to load this chat. Your message was not sent.' + ) + }) +}) + +describe('a send refused on a journal a newer Orca wrote', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('says to update Orca, not to try again', async () => { + // As the host answers it (pinned in `journal-open-failure.test.ts`). + mocks.call.mockResolvedValue({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + + await waitFor(() => + expect(shownFailure(result.current.outbox[0])).toBe( + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + ) + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index 8cc467a3d283..db9f70e7ba94 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -155,6 +155,38 @@ describe('a chat write the host refused', () => { ) }) + it('says to update Orca for a Stop refused on a journal a newer Orca wrote', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: false, + // As the host answers it (pinned in `journal-open-failure.test.ts`). + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.cancel('turn-1')).resolves.toBeNull() + }) + + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + "Chats were saved by a newer Orca. The agent wasn't stopped. Update Orca to keep using them." + ) + }) + it('answers a refused conversation command inline, where the command was typed', async () => { mocks.call.mockImplementation((_target, method) => method === 'agentSession.options' diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index fea3ef62d527..1f62a31e0d5e 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2793,6 +2793,7 @@ "recordUnreadable": "Orca couldn't read this chat's saved state.", "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", + "savedByNewerOrca": "Chats were saved by a newer Orca.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", "startNewChat": "Start a new chat to continue.", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", @@ -2801,6 +2802,7 @@ "turnActive": "The agent is still responding.", "unreachable": "Orca couldn't reach the agent.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", + "updateOrcaToKeepUsing": "Update Orca to keep using them.", "waitForBackgroundTasks": "Wait for the background tasks to finish.", "waitForCommand": "Wait for the /compact or /clear to finish.", "waitForStart": "Wait for the agent to finish starting.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 91f2275b0e2e..9dc86eadf8d2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17509,6 +17509,8 @@ "historyUnreadable": "Orca couldn't read this chat's saved history.", "historyUnusable": "Unable to load this chat.", "historyUnavailable": "Orca couldn't open this chat's history right now.", + "savedByNewerOrca": "Chats were saved by a newer Orca.", + "updateOrcaToKeepUsing": "Update Orca to keep using them.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", "unreachable": "Orca couldn't reach the agent.", "recordFailed": "Orca couldn't record it in this chat's history.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 3aff95b8d5e0..96f5367b28af 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14958,6 +14958,8 @@ "historyUnreadable": "Orca no pudo leer el historial guardado de este chat.", "historyUnusable": "No se puede cargar este chat.", "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", + "savedByNewerOrca": "Los chats se guardaron con una versión más reciente de Orca.", + "updateOrcaToKeepUsing": "Actualice Orca para seguir usándolos.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", "unreachable": "Orca no pudo comunicarse con el agente.", "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 42058065579b..084b95c2782c 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17441,6 +17441,8 @@ "historyUnreadable": "Orca n'a pas pu lire l'historique enregistré de ce chat.", "historyUnusable": "Impossible de charger ce chat.", "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", + "savedByNewerOrca": "Les chats ont été enregistrés par une version plus récente d'Orca.", + "updateOrcaToKeepUsing": "Mettez à jour Orca pour continuer à les utiliser.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", "unreachable": "Orca n'a pas pu joindre l'agent.", "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index cc86474fe8b8..99b9baa64400 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17377,6 +17377,8 @@ "historyUnreadable": "Orca はこのチャットの保存済み履歴を読み取れませんでした。", "historyUnusable": "このチャットを読み込めません。", "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", + "savedByNewerOrca": "チャットは新しいバージョンの Orca で保存されました。", + "updateOrcaToKeepUsing": "引き続き使用するには、Orca を更新してください。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index aa8bf3912d13..f1f9e90aa8ac 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17377,6 +17377,8 @@ "historyUnreadable": "Orca가 이 채팅의 저장된 기록을 읽지 못했습니다.", "historyUnusable": "이 채팅을 불러올 수 없습니다.", "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", + "savedByNewerOrca": "채팅이 최신 버전의 Orca에서 저장되었습니다.", + "updateOrcaToKeepUsing": "계속 사용하려면 Orca를 업데이트하세요.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e61395b42918..e2b8b5d68ea1 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17342,6 +17342,8 @@ "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", "historyUnusable": "无法加载此聊天。", "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", + "savedByNewerOrca": "这些聊天由更新版本的 Orca 保存。", + "updateOrcaToKeepUsing": "请更新 Orca 以继续使用这些聊天。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", diff --git a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts index 7ba8dee68f8f..2295b8fd9589 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts @@ -119,6 +119,60 @@ describe('structured launch cancellation retirement', () => { ) }) + // A runtime refused its chats answers with no chat rows; that absence is not the host's answer. + it('keeps a tombstone through an inventory that cannot list chats', async () => { + setStructuredLaunchState({ + identity: `codex:${WORKTREE_ID}`, + intent: { + worktreeId: WORKTREE_ID, + sessionId: SESSION_ID, + agent: 'codex', + params: { + envelope: { + sessionId: SESSION_ID, + clientOperationId: 'operation-unverifiable', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint-unverifiable' + }, + worktree: `id:${WORKTREE_ID}`, + agent: 'codex' + } + }, + promptDelivery: 'auto-submit', + callers: { + outcome: 'pending', + entries: new Set(), + promptDeliveryResults: new Set(), + onSettled: () => undefined + }, + promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }), + visibilityUnknown: false, + cancelled: false, + selection: { held: {} } + } satisfies StructuredLaunchState) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID) + await new Promise((resolve) => setTimeout(resolve, 0)) + const frame = (unverifiable: boolean): RuntimeMobileSessionTabsResult => ({ + ...latePublication(), + activeTabId: null, + activeTabType: null, + tabs: [], + ...(unverifiable ? { agentSessionsUnverifiable: true as const } : {}) + }) + const call = vi.fn() + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + + call.mockResolvedValue({ ok: true, result: { snapshots: [frame(true)], authoritative: true } }) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + + call.mockResolvedValue({ ok: true, result: { snapshots: [frame(false)], authoritative: true } }) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + }) + it('drains a restored cancellation before a newer inventory retires it', async () => { markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID) resetStructuredAgentLaunchRegistryForTests() diff --git a/src/renderer/src/runtime/host-session-snapshot-authority.ts b/src/renderer/src/runtime/host-session-snapshot-authority.ts index 532d8497b861..336bdf9fe6d9 100644 --- a/src/renderer/src/runtime/host-session-snapshot-authority.ts +++ b/src/renderer/src/runtime/host-session-snapshot-authority.ts @@ -9,6 +9,10 @@ type ClientHostedPagePublication = SnapshotPublication & { clientHostedPagesUnreconciled?: true } +type AgentSessionPublication = SnapshotPublication & { + agentSessionsUnverifiable?: true +} + /** * Whether a session-tabs snapshot carries the host's answer about a worktree at all. * @@ -42,3 +46,15 @@ export function hostSnapshotAffirmsClientHostedPages( ): boolean { return hostSnapshotAffirmsWorktreeContents(snapshot) && !snapshot.clientHostedPagesUnreconciled } + +/** + * Whether a snapshot's `agent-session` rows are the host's answer about which chats exist. + * + * Narrower than {@link hostSnapshotAffirmsWorktreeContents} for the same reason as client-hosted + * pages: a runtime whose chat journal will not open is authoritative about terminals but cannot + * list a single chat. Its empty chat set is "cannot tell", and culling on it would delete tabs + * whose chats are safe on disk. + */ +export function hostSnapshotAffirmsAgentSessions(snapshot: AgentSessionPublication): boolean { + return hostSnapshotAffirmsWorktreeContents(snapshot) && !snapshot.agentSessionsUnverifiable +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index 7928fcb5aca0..f9e35cb056b5 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -28,7 +28,10 @@ import { } from './inventory-generation-fence' import { forgetRetiredEpochRepairsOutside } from './retired-epoch-repair' import { projectLocalStructuredSessionTabs } from './snapshot-projection' -import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' +import { + hostSnapshotAffirmsAgentSessions, + hostSnapshotAffirmsWorktreeContents +} from '../host-session-snapshot-authority' import { hasStructuredAgentSessionLaunchCancellationTombstone, markStructuredAgentSessionLaunchPublished, @@ -92,6 +95,9 @@ export function applyStructuredSessionTabSnapshots( startStructuredAgentLaunchCancellationCleanup((sessionId) => closeStructuredAgentSession({ kind: 'local' }, sessionId) ) + } + // A chat missing from an inventory that cannot list chats is not proof the host dropped it. + if (options.authoritative && snapshots.every(hostSnapshotAffirmsAgentSessions)) { retireAbsentStructuredAgentSessionLaunchCancellationTombstones( new Set( snapshots.flatMap((snapshot) => diff --git a/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts b/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts new file mode 100644 index 000000000000..ad2e7380b8b4 --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom + +// A runtime whose chat journal will not open still answers the tab inventory, but cannot list a +// single chat. Its empty chat set must not delete the saved chat tabs, or the next session save +// persists their placement away. + +import { afterEach, describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs' +import { + applyLocalStructuredSessionTabSnapshots, + resetLocalStructuredSessionVersionForTests +} from './local-structured-session-tabs-sync' +import { + applyWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::worktree-1' +const TERMINAL_ID = 'terminal-1' +const CLAUDE_TAB = 'agent-session:claude-1' +const CODEX_TAB = 'agent-session:codex-1' +const PRIMARY_GROUP = 'primary-group' +const CHAT_GROUP = 'chat-group' + +afterEach(() => { + resetLocalStructuredSessionVersionForTests() + resetWebSessionTabsSnapshotFreshnessForTests() +}) + +function chatTab(id: string, entityId: string, agent: 'claude' | 'codex', sortOrder: number): Tab { + return { + id, + entityId, + groupId: CHAT_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: agent, + label: agent === 'claude' ? 'Claude Chat' : 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +/** A terminal beside a split group holding a saved Claude chat and a saved Codex chat. */ +function hydratedState(): WebSessionTabsSyncState { + const tabs: Tab[] = [ + { + id: TERMINAL_ID, + entityId: TERMINAL_ID, + groupId: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + chatTab(CLAUDE_TAB, 'claude-1', 'claude', 1), + chatTab(CODEX_TAB, 'codex-1', 'codex', 2) + ] + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: CHAT_GROUP }, + activeTabId: CODEX_TAB, + activeTabIdByWorktree: { [WORKTREE_ID]: CODEX_TAB }, + activeTabType: 'agent-session', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'agent-session' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: TERMINAL_ID, + tabOrder: [TERMINAL_ID] + }, + { + id: CHAT_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: CODEX_TAB, + tabOrder: [CLAUDE_TAB, CODEX_TAB] + } + ] + }, + layoutByWorktree: { + [WORKTREE_ID]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: PRIMARY_GROUP }, + second: { type: 'leaf', groupId: CHAT_GROUP } + } + }, + openFiles: [], + ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] }, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, CLAUDE_TAB, CODEX_TAB] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: tabs }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } +} + +/** What a refused runtime publishes for the worktree: a real frame with no chat rows. */ +function frameWithoutChats(unverifiable: boolean): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 3, + activeGroupId: PRIMARY_GROUP, + activeTabId: null, + activeTabType: null, + tabGroups: [{ id: PRIMARY_GROUP, activeTabId: null, tabOrder: [] }], + tabs: [], + ...(unverifiable ? { agentSessionsUnverifiable: true as const } : {}) + } +} + +function chatTabIds(state: WebSessionTabsSyncState): string[] { + return (state.unifiedTabsByWorktree[WORKTREE_ID] ?? []) + .filter((tab) => tab.contentType === 'agent-session') + .map((tab) => tab.id) +} + +function savedChatTabs(state: WebSessionTabsSyncState): Tab[] { + const session: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: state.activeTabId, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...buildPersistedUnifiedTabSessionData(state) + } + return (session.unifiedTabs?.[WORKTREE_ID] ?? []).filter( + (tab) => tab.contentType === 'agent-session' + ) +} + +describe('an inventory that cannot list chats', () => { + it('keeps the saved chat tabs in place, and the next session save still has them', () => { + const before = hydratedState() + + const applied = applyLocalStructuredSessionTabSnapshots(before, [frameWithoutChats(true)]) + + expect(chatTabIds(applied)).toEqual([CLAUDE_TAB, CODEX_TAB]) + expect(applied.groupsByWorktree[WORKTREE_ID]).toEqual(before.groupsByWorktree[WORKTREE_ID]) + expect(applied.layoutByWorktree[WORKTREE_ID]).toEqual(before.layoutByWorktree[WORKTREE_ID]) + expect(applied.activeTabIdByWorktree[WORKTREE_ID]).toBe(CODEX_TAB) + expect(savedChatTabs(applied)).toEqual([ + expect.objectContaining({ id: CLAUDE_TAB, groupId: CHAT_GROUP, agentSessionAgent: 'claude' }), + expect.objectContaining({ id: CODEX_TAB, groupId: CHAT_GROUP, agentSessionAgent: 'codex' }) + ]) + }) + + // The mechanism the flag stands in front of: an affirmed chat set is the host's answer. + it('still removes chat tabs an affirmed inventory leaves out', () => { + const applied = applyLocalStructuredSessionTabSnapshots(hydratedState(), [ + frameWithoutChats(false) + ]) + + expect(chatTabIds(applied)).toEqual([]) + expect(savedChatTabs(applied)).toEqual([]) + }) + + it('keeps them on a paired client applying the same frame', () => { + const state = hydratedState() + + const patch = applyWebSessionTabsSnapshot( + state, + frameWithoutChats(true), + 'environment-1', + 1_700_000_000_000, + { contentScope: 'agent-session' } + ) + const applied = { ...state, ...patch } + + expect(chatTabIds(applied)).toEqual([CLAUDE_TAB, CODEX_TAB]) + expect(savedChatTabs(applied)).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index 4dcfd7ae44db..c88d7ebe1a69 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -7,7 +7,10 @@ import { } from './mirrored-browser-tabs' import { buildMirroredEditorTabs } from './tab-builders' import { buildMirroredAgentTabs, isReadyBrowserTab, isReadyEditorTab } from './terminal-surfaces' -import { hostSnapshotAffirmsClientHostedPages } from '../host-session-snapshot-authority' +import { + hostSnapshotAffirmsAgentSessions, + hostSnapshotAffirmsClientHostedPages +} from '../host-session-snapshot-authority' import type { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base' import type { OpenFile } from '../../store/slices/editor' import { @@ -39,6 +42,7 @@ export function prepareWebSessionTabsSnapshotBrowser( const publishedAgentSessionIds = new Set( snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.sessionId) ) + const agentSessionsAffirmed = hostSnapshotAffirmsAgentSessions(snapshot) const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) @@ -177,9 +181,11 @@ export function prepareWebSessionTabsSnapshotBrowser( if (tab.contentType === 'agent-session') { // A matching host row is authoritative; retaining the provisional tab beside its mirror // would briefly render two panes before lifecycle publication is recorded. + // A host that cannot list its chats is no evidence this one closed. return ( !publishedAgentSessionIds.has(tab.entityId) && - shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) + (!agentSessionsAffirmed || + shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)) ) } if (tab.contentType === 'browser') { diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index eea4e305fccd..9e697c10b53f 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -96,7 +96,9 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** SQLite reports the chat's journal damaged or not a database; no retry reads past it. */ 'journalCorrupt', /** Any other failed open, which can clear. */ - 'journalUnavailable' + 'journalUnavailable', + /** A newer Orca wrote the journal, or this chat's rows; only an update writes past it. */ + 'journalWrittenByNewerOrca' ], structured_agent_session_unsupported: [ 'clientCapabilityMissing', diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index a83a95c43880..cdd3639e6a7a 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -362,7 +362,10 @@ describe('the notice for every reason a host names', () => { if (words && 'cause' in words) { const retryNow = words.action === 'retry' && words.step === 'tryAgain' expect(words.step !== undefined && !retryNow, cell).toBe( - words.action === 'wait' || words.action === 'actFirst' || words.action === 'goElsewhere' + words.action === 'wait' || + words.action === 'actFirst' || + words.action === 'goElsewhere' || + words.action === 'updateOrca' ) expect(retryNow, cell).toBe(words.cause === 'historyUnavailable') } @@ -534,6 +537,22 @@ describe('a chat whose history the host could not open', () => { 'journalUnavailable', 'send', "Orca couldn't open this chat's history right now. Your message was not sent. Try again." + ], + // Only an update gets past it, so it never says to try again. + [ + 'journalWrittenByNewerOrca', + 'send', + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + ], + [ + 'journalWrittenByNewerOrca', + 'stop', + "Chats were saved by a newer Orca. The agent wasn't stopped. Update Orca to keep using them." + ], + [ + 'journalWrittenByNewerOrca', + 'answer', + 'Chats were saved by a newer Orca. Your answer was not sent. Update Orca to keep using them.' ] ] as const)('%s on %s', (reason, write, expected) => { expect(notice({ reason }, write)).toBe(expected) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 64cdf19e917e..6c2afcfebf70 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -200,7 +200,8 @@ const REASON_WORDS = { // No retry reads past damage, and the words name no step: it only can't load. journalCorrupt: causeWords('historyUnusable', 'hostFinding'), // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), + journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') }, // Thrown, so a client meets these only as an RPC error; the code's words stand. structured_agent_session_unsupported: { @@ -226,11 +227,12 @@ export function agentSessionRefusalReasonWords( return reason === undefined ? undefined : byReason?.[reason] } -// Each already says the history was not read. +// Each already says the history can't be read here. const HISTORY_CAUSES: ReadonlySet = new Set([ 'historyUnusable', 'historyUnavailable', - 'historyUnreadable' + 'historyUnreadable', + 'savedByNewerOrca' ]) /** A cause, and that the request did not happen unless the cause already says so. */ diff --git a/src/shared/agent-session-write-failure.test.ts b/src/shared/agent-session-write-failure.test.ts index 0b614b3a2650..7fd8b0be3f3c 100644 --- a/src/shared/agent-session-write-failure.test.ts +++ b/src/shared/agent-session-write-failure.test.ts @@ -164,4 +164,31 @@ describe('a refusal a failed request carried in its error', () => { code: 'structured_agent_session_unsupported' }) }) + + it("keeps a journal refusal's reason", () => { + const corrupt = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + // Not "Orca couldn't confirm what happened": the host refused it before running it. + expect( + agentSessionThrownFailure(saved({ ...payload, data: { refusal: corrupt } }), 'runtime_error') + ).toEqual({ + kind: 'refused', + ...corrupt + }) + }) + + it("degrades a reason another build added to the code's own words", () => { + const newer = { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalFromTheFuture' } + } + expect( + agentSessionThrownFailure({ ...payload, data: { refusal: newer } }, 'runtime_error') + ).toEqual({ + kind: 'refused', + code: 'agent_session_journal_unreadable' + }) + }) }) diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index d0c36e27a407..97bd97cbf811 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -26,6 +26,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { historyUnreadable: "Orca couldn't read this chat's saved history.", historyUnusable: 'Unable to load this chat.', historyUnavailable: "Orca couldn't open this chat's history right now.", + savedByNewerOrca: 'Chats were saved by a newer Orca.', + updateOrcaToKeepUsing: 'Update Orca to keep using them.', unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", unreachable: "Orca couldn't reach the agent.", recordFailed: "Orca couldn't record it in this chat's history.", diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 393c95fec87f..1d59572f9465 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -264,6 +264,15 @@ export type RuntimeMobileSessionTabsResult = { * host that never returns cannot hold rows open forever. */ clientHostedPagesUnreconciled?: true + /** + * Set while this runtime has no structured-chat host that can say which chats exist because the + * chat journal will not open. The snapshot is still authoritative about everything else, but its + * missing `agent-session` rows mean "cannot tell", not "closed". + * + * Cleared by the first tab restore a host answers. Not bounded by a deadline: the chats are + * durable on disk, so holding their tabs strands nothing. + */ + agentSessionsUnverifiable?: true } export type RuntimeMobileSessionCreateTerminalResult = { diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index ce6143369ecd..ea463c94f324 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -15,7 +15,10 @@ import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' -import { agentSessionRefusalFailure } from './agent-session-write-failure' +import { + agentSessionRefusalFailure, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' import type { AgentSessionFailureFact } from './agent-session-failure' import { agentSessionFailureSentence, @@ -191,6 +194,43 @@ export function structuredAgentSessionAttemptFailureParts( return structuredAgentSessionRejectionParts(failure.reason, 'send', fact, context) } +/** A send the host refused, whether it returned the refusal or threw it. */ +export function disposeStructuredAgentSessionSendRefusal( + input: SendDispositionInput & { + refusal: AgentSessionWriteRefusal + createOperationId: () => string + } +): StructuredAgentSessionSendDisposition { + const refusedIndex = input.entries.findIndex( + (candidate) => candidate.clientMessageId === input.entry.clientMessageId + ) + const entries = input.entries.map((candidate) => + candidate.clientMessageId === input.entry.clientMessageId + ? withLastFailure( + requeueStructuredAgentSessionSendRefusal( + candidate, + input.refusal, + input.createOperationId, + input.entry.lastAttemptAt !== null + ), + input.refusal + ) + : candidate + ) + const refused = entries[refusedIndex] + return { + entries, + error: null, + // Read back by index rather than from the input: a refusal can rotate the id, and the + // refused entry is not always the head now that an admitted one no longer holds the queue. + // A rejected one holds nothing: it can no longer land, and it keeps its own Retry. + blockedClientMessageId: + !refused || refused.state === 'rejected' + ? input.blockedClientMessageId + : refused.clientMessageId + } +} + export function disposeStructuredAgentSessionSendResult( input: SendDispositionInput & { result: AgentSessionMutationResult @@ -199,35 +239,10 @@ export function disposeStructuredAgentSessionSendResult( ): StructuredAgentSessionSendDisposition { const result = input.result if (!result.ok) { - const refusedIndex = input.entries.findIndex( - (candidate) => candidate.clientMessageId === input.entry.clientMessageId - ) - const refusal = agentSessionRefusalFailure(result.refusal) - const entries = input.entries.map((candidate) => - candidate.clientMessageId === input.entry.clientMessageId - ? withLastFailure( - requeueStructuredAgentSessionSendRefusal( - candidate, - refusal, - input.createOperationId, - input.entry.lastAttemptAt !== null - ), - refusal - ) - : candidate - ) - const refused = entries[refusedIndex] - return { - entries, - error: null, - // Read back by index rather than from the input: a refusal can rotate the id, and the - // refused entry is not always the head now that an admitted one no longer holds the queue. - // A rejected one holds nothing: it can no longer land, and it keeps its own Retry. - blockedClientMessageId: - !refused || refused.state === 'rejected' - ? input.blockedClientMessageId - : refused.clientMessageId - } + return disposeStructuredAgentSessionSendRefusal({ + ...input, + refusal: agentSessionRefusalFailure(result.refusal) + }) } if ('queued' in result.value) { // The host holds the draft (or already settled it, on a replay). Either way diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index 71bb0fddce18..dbb2a5d8e9cb 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -22,7 +22,7 @@ import type { Tab } from '../../src/shared/tab-types' import type { AppState } from '../../src/renderer/src/store/types' import { createCodexJournalTranslator } from '../../src/main/codex/codex-structured-journal-translation' import { CODEX_COMMAND_APPROVAL_METHOD } from '../../src/main/codex/codex-structured-prompt-replies' -import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-status-feed' import { indexedStatusFeedSession } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' @@ -140,7 +140,7 @@ async function openHost() { providerHandle: { kind: 'codex', threadId: CODEX_THREAD } }, now: tick, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ 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 3087385da1d5..981731cd40f1 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 @@ -60,6 +60,7 @@ import { type RpcClientIdentity, type RpcReply } from './versioned-agent-session-wire' +import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -498,7 +499,7 @@ describe('cross-version structured agent sessions', () => { answerPrompt: async () => undefined, setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-vault', now: () => NOW @@ -707,7 +708,7 @@ describe('cross-version structured agent sessions', () => { const host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation}`, // The provider died with the host that spawned it, which is what makes diff --git a/tests/e2e/structured-agent-session-read-owner.unit.test.ts b/tests/e2e/structured-agent-session-read-owner.unit.test.ts index 4e64a047efcd..dafa6e8089df 100644 --- a/tests/e2e/structured-agent-session-read-owner.unit.test.ts +++ b/tests/e2e/structured-agent-session-read-owner.unit.test.ts @@ -17,7 +17,7 @@ import { hasUnansweredStructuredAgentSessionDispatch, projectStructuredAgentSessionStatus } from '../../src/shared/structured-agent-session-projection' -import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../../src/main/native-chat/agent-session-wire/agent-session-history-page' import { AgentSessionSubscribers } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-subscribers' import { StructuredAgentSessionStatusFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-status-feed' @@ -57,7 +57,7 @@ async function fixture() { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'journal') + stateDirectory: join(root, 'journal') }) async function appendOutput(index: number) { await journal.appendItem( 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 3d8858b3658b..dff6b466ec74 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -23,6 +23,7 @@ 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' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' +import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' const WORKTREE = 'repo-1::/workspace/repo' @@ -53,7 +54,7 @@ function openHost(): void { answerPrompt: async () => undefined, setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', idleSweep: { intervalMs: 5 },