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