From c1bbffc3cdccc7de1b5339efba84ec2edfd281f1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:00:04 -0700 Subject: [PATCH 01/66] feat(native-chat): one structured-chat journal database per host, owned by one process Every structured chat on a state directory now lives in one SQLite file, agent-session-journal.db, opened once by the process holding agent-session-journal.owner: an empty SQLite file whose held BEGIN EXCLUSIVE is a kernel byte-range lock, refused while another process holds it and released when the holder dies. - Stores own no connection: the per-chat handle, its close contract and the close-retry registry are gone; closing a conversation drains its writes, and the one connection closes last at teardown. - The owner lock is taken at runtime start, before orca-runtime.json is written; a process that does not own the chats is not published and refuses every structured request with journalUnavailable and words that say what to do. It retries the lock with backoff and runs the full install once it holds it. - A journal that will not open fails the host install: every chat says "Unable to load this chat." (journalCorrupt), and nothing is renamed, deleted or rebuilt. A newer build's database is refused and left byte-identical. - An append is one INSERT. The listing status is a column, written after the rows it describes and keyed by (epoch, sequence). - A per-chat journal from an earlier build is copied in verbatim (epoch UUID and every sequence) on that chat's first open, and its directory is retired only after the copy commits. - auto_vacuum = INCREMENTAL, with freed pages handed back in bounded steps after every delete. --- .../journal-replay-retention-benchmark.mjs | 16 +- .../session-search-retention-delete.ts | 11 +- .../claude-api-retry-idle-sweep.test.ts | 3 +- src/main/claude/claude-api-retry-row.test.ts | 3 +- .../claude-context-usage-restart.test.ts | 4 +- ...claude-context-usage-unloaded-turn.test.ts | 4 +- .../claude-stream-json-connection.test.ts | 3 +- ...ude-structured-journal-translation.test.ts | 7 +- ...laude-structured-provider-fallback.test.ts | 3 +- ...x-structured-journal-goal-revision.test.ts | 6 +- ...tructured-journal-subagent-readers.test.ts | 3 +- ...journal-translation-turn-lifecycle.test.ts | 4 +- .../codex-structured-question-order.test.ts | 3 +- .../journal-close-retry.ts | 68 ----- .../journal-corruption-repair.test.ts | 25 +- .../journal-crash-boundary.test.ts | 4 +- .../journal-database-schema.ts | 29 +- .../journal-database.test.ts | 153 +++++----- .../agent-session-journal/journal-database.ts | 66 +++-- .../journal-dispatch-reason-bound.test.ts | 4 +- .../journal-epoch-controller.ts | 11 +- .../journal-epoch-replacement.test.ts | 45 ++- .../journal-epoch-replacement.ts | 30 +- .../journal-epoch-rollover.ts | 39 ++- .../journal-file-format-remnant.test.ts | 74 +++-- .../journal-handle-ownership.test.ts | 161 ----------- .../journal-host-database-test-support.ts | 77 +++++ .../journal-host-database.test.ts | 137 +++++++++ .../journal-host-database.ts | 121 ++++++++ .../journal-legacy-import.test.ts | 17 +- .../journal-open-failure.test.ts | 21 +- .../journal-open-failure.ts | 39 +++ .../agent-session-journal/journal-open.ts | 43 +-- .../journal-owner-lock-test-support.ts | 81 ++++++ .../journal-owner-lock.test.ts | 139 +++++++++ .../journal-owner-lock.ts | 122 ++++++++ .../agent-session-journal/journal-paths.ts | 31 +- .../journal-per-session-import.test.ts | 233 +++++++++++++++ .../journal-per-session-import.ts | 158 ++++++++++ .../journal-producer-inheritance.test.ts | 4 +- .../journal-repair-marker.ts | 20 +- .../journal-restart-reconciliation.test.ts | 4 +- .../journal-row-context-usage.test.ts | 13 +- .../journal-row-schema-version.test.ts | 13 +- .../journal-row-table.ts | 57 ++-- .../journal-row-writer.test.ts | 14 +- .../journal-row-writer.ts | 18 +- .../journal-session-status.test.ts | 114 ++++++++ .../journal-session-status.ts | 18 ++ .../journal-store-close.test.ts | 270 ------------------ .../journal-store-close.ts | 106 ------- .../journal-store-collaborators.ts | 11 +- .../journal-store-contracts.ts | 6 +- .../journal-store-open.ts | 14 +- .../journal-store-restore.ts | 17 +- .../journal-store-schema.test.ts | 70 +++-- .../journal-store-test-open.ts | 34 --- .../journal-store.test.ts | 33 +-- .../agent-session-journal/journal-store.ts | 122 ++++---- .../journal-streaming-replay.test.ts | 24 +- .../journal-subagent-liveness.test.ts | 4 +- .../journal-write-queue.ts | 42 +++ ...nt-session-history-byte-accounting.test.ts | 4 +- ...ession-history-forward-read-budget.test.ts | 87 +++--- .../agent-session-history-page.test.ts | 15 +- .../agent-session-journal-recovery.test.ts | 149 +++++----- .../agent-session-journal-recovery.ts | 80 ++---- ...-agent-session-accept-then-deliver.test.ts | 9 +- ...-agent-session-acquisition-options.test.ts | 44 +-- ...tured-agent-session-adopted-import.test.ts | 11 +- ...ured-agent-session-append-delivery.test.ts | 3 +- .../structured-agent-session-attach-flow.ts | 2 - ...ured-agent-session-attach-orchestration.ts | 1 - ...gent-session-attach-reconciliation.test.ts | 22 +- ...-agent-session-attach-test-conversation.ts | 5 +- .../structured-agent-session-attach.ts | 1 - ...-agent-session-claude-option-queue.test.ts | 3 +- ...red-agent-session-claude-root-exit.test.ts | 14 +- ...ructured-agent-session-close-retry.test.ts | 267 ----------------- ...-agent-session-command-publication.test.ts | 4 +- ...uctured-agent-session-conversation-open.ts | 9 +- ...ctured-agent-session-conversations.test.ts | 4 +- ...ured-agent-session-crash-mid-start.test.ts | 3 +- ...session-dead-generation-settlement.test.ts | 5 +- ...ession-failed-create-owner-verdict.test.ts | 3 +- ...session-failed-create-sink-release.test.ts | 3 +- ...ctured-agent-session-forget-status.test.ts | 13 +- ...tured-agent-session-grouped-prompt.test.ts | 3 +- .../structured-agent-session-host-lifetime.ts | 12 +- .../structured-agent-session-host-teardown.ts | 20 +- ...ed-agent-session-host-test-abandon.test.ts | 3 +- ...uctured-agent-session-host-test-harness.ts | 9 +- .../structured-agent-session-host-types.ts | 4 +- .../structured-agent-session-host.test.ts | 5 +- ...d-agent-session-journal-corruption.test.ts | 61 ++++ ...ured-agent-session-journal-handles.test.ts | 235 --------------- ...ured-agent-session-late-settlement.test.ts | 3 +- ...gent-session-legacy-handoff-record.test.ts | 3 +- ...agent-session-operation-settlement.test.ts | 2 +- ...ed-agent-session-option-settlement.test.ts | 3 +- ...ed-agent-session-owed-work-release.test.ts | 3 +- ...ent-session-pre-spawn-first-answer.test.ts | 5 +- ...nt-session-processless-reservation.test.ts | 21 +- ...ctured-agent-session-prompt-cancel.test.ts | 4 +- ...gent-session-provider-child-record.test.ts | 9 +- ...red-agent-session-provider-restore.test.ts | 3 +- ...red-agent-session-provider-started.test.ts | 3 +- ...uctured-agent-session-read-restore.test.ts | 7 +- .../structured-agent-session-read-restore.ts | 26 +- ...agent-session-recovered-turn-clock.test.ts | 4 +- ...tured-agent-session-recovery-exits.test.ts | 3 +- ...ctured-agent-session-refusal-retry.test.ts | 3 +- .../structured-agent-session-rest-test-rig.ts | 3 +- ...ssion-restart-interruption-test-harness.ts | 3 +- ...gent-session-restart-offer-endings.test.ts | 23 +- ...session-restart-status-publication.test.ts | 3 +- ...tured-agent-session-rewind-at-rest.test.ts | 3 +- .../structured-agent-session-rewind.test.ts | 3 +- ...red-agent-session-send-idempotency.test.ts | 4 +- ...agent-session-send-open-stale-turn.test.ts | 3 +- ...red-agent-session-send-preparation.test.ts | 3 +- ...ructured-agent-session-send-preparation.ts | 18 +- ...session-send-restarts-failed-start.test.ts | 3 +- ...agent-session-settled-attach-retry.test.ts | 13 +- ...d-agent-session-stale-turn-verdict.test.ts | 4 +- ...agent-session-start-failure-writer.test.ts | 3 +- ...ent-session-status-feed-child-work.test.ts | 4 +- ...ed-agent-session-status-feed-clock.test.ts | 4 +- ...ructured-agent-session-status-feed.test.ts | 4 +- .../structured-agent-session-status-feed.ts | 4 + ...tured-agent-session-status-reentry.test.ts | 4 +- ...red-agent-session-subagent-recency.test.ts | 4 +- ...ructured-agent-session-subscribers.test.ts | 31 +- ...red-agent-session-surface-lifetime.test.ts | 3 +- ...ructured-agent-session-thread-goal.test.ts | 4 +- .../structured-agent-session-turns.test.ts | 12 +- .../structured-agent-session-turns.ts | 10 +- ...sion-view-start-after-failed-start.test.ts | 3 +- ...t-session-wedged-profile-migration.test.ts | 6 +- ...tured-agent-session-wire-admission.test.ts | 15 +- .../structured-conversation-command.test.ts | 3 +- src/main/orcad/orcad-entry.ts | 4 + ...ured-agent-session-adoption-replay.test.ts | 3 +- .../structured-agent-session-at-rest.test.ts | 20 +- .../methods/structured-agent-session-gate.ts | 29 +- .../structured-agent-session-hold.test.ts | 3 +- .../methods/structured-chat-tab-table.test.ts | 3 +- .../runtime-rpc/runtime-rpc-lifecycle.ts | 45 ++- .../runtime-rpc/runtime-rpc-pairing-types.ts | 6 + .../runtime-rpc/runtime-rpc-shutdown.ts | 2 + .../runtime/runtime-rpc/runtime-rpc-state.ts | 4 + ...untime-worktree-agent-rows-verdict.test.ts | 4 +- ...ree-structured-agent-rows-liveness.test.ts | 4 +- ...d-agent-session-integration-replay.test.ts | 5 +- ...ructured-agent-session-integration.test.ts | 9 +- ...ed-agent-session-journal-ownership.test.ts | 217 ++++++++++++++ ...uctured-agent-session-journal-ownership.ts | 131 +++++++++ ...ructured-agent-session-runtime-teardown.ts | 12 +- .../structured-agent-session-runtime.test.ts | 70 ----- .../structured-agent-session-runtime.ts | 71 ++++- .../structured-claude-pending-rewind.test.ts | 3 +- src/main/sqlite/sqlite-free-page-reclaim.ts | 18 ++ .../startup/main-process-runtime-launch.ts | 5 +- .../structured-agent-session-saved-status.ts | 25 ++ ...x-child-approval-activity-row.unit.test.ts | 4 +- ...ss-version-agent-session-wire.unit.test.ts | 5 +- ...ured-agent-session-read-owner.unit.test.ts | 4 +- ...-chat-owner-status-activation.unit.test.ts | 3 +- 168 files changed, 2899 insertions(+), 2251 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-close-retry.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-host-database.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-host-database.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-owner-lock-test-support.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-owner-lock.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-per-session-import.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-session-status.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-session-status.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-store-close.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-store-close.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-store-test-open.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-write-queue.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-journal-corruption.test.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts create mode 100644 src/main/runtime/structured-agent-session-journal-ownership.test.ts create mode 100644 src/main/runtime/structured-agent-session-journal-ownership.ts create mode 100644 src/main/sqlite/sqlite-free-page-reclaim.ts create mode 100644 src/shared/structured-agent-session-saved-status.ts diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs index 3991a886ab59..7240a340eab5 100644 --- a/config/scripts/journal-replay-retention-benchmark.mjs +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -23,7 +23,7 @@ try { 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';", + "export {openAgentSessionJournal} from './src/main/native-chat/agent-session-journal/journal-store-factory'; export {replayJournal} from './src/main/native-chat/agent-session-journal/journal-open'; export {JournalHostDatabase, journalDatabasePath} from './src/main/native-chat/agent-session-journal/journal-host-database'; export {tryAcquireJournalOwnerLock} from './src/main/native-chat/agent-session-journal/journal-owner-lock';", resolveDir: root }, bundle: true, @@ -69,8 +69,10 @@ try { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread' } } - const journalDir = join(fixture, 'session') - const journal = await implementations.current.openAgentSessionJournal({ identity, journalDir }) + const stateDirectory = join(fixture, 'state') + const lock = implementations.current.tryAcquireJournalOwnerLock(stateDirectory) + const database = implementations.current.JournalHostDatabase.open(lock) + const journal = await implementations.current.openAgentSessionJournal({ identity, database }) const item = { provider: 'codex', threadId: 'thread', turnId: 'turn', ordinal: 0 } const text = 'x'.repeat(32768) for (let revision = 0; revision < 2000; revision++) { @@ -88,7 +90,7 @@ try { for (const arm of ['baseline', 'current', 'current', 'baseline']) { global.gc() const start = performance.now() - let loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + let loaded = implementations[arm].replayJournal(database.db, identity.sessionId) const ms = performance.now() - start assert.equal(loaded.state.items.size, 1) assert.equal([...loaded.state.items.values()][0].revision, 2000) @@ -103,7 +105,7 @@ try { global.gc() peakLiveHeap = Math.max(peakLiveHeap, process.memoryUsage().heapUsed) } - loaded = implementations[arm].loadJournal(journalDir, identity.sessionId) + loaded = implementations[arm].replayJournal(database.db, identity.sessionId) delete globalThis.__replayMemoryProbe assert.equal(loaded.state.items.size, 1) loaded = null @@ -111,11 +113,13 @@ try { JSON.stringify({ arm, ms, - databaseBytes: (await stat(implementations[arm].journalDatabaseFile(journalDir))).size, + databaseBytes: (await stat(implementations[arm].journalDatabasePath(stateDirectory))).size, peakLiveHeapDelta: peakLiveHeap - initialHeap }) ) } + database.close() + lock.release() } finally { delete globalThis.__replayMemoryProbe await rm(fixture, { recursive: true, force: true }) diff --git a/src/main/ai-vault-search/session-search-retention-delete.ts b/src/main/ai-vault-search/session-search-retention-delete.ts index e8d407f8be91..f64f9484259d 100644 --- a/src/main/ai-vault-search/session-search-retention-delete.ts +++ b/src/main/ai-vault-search/session-search-retention-delete.ts @@ -1,13 +1,9 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type SyncDatabase from '../sqlite/sync-database' +import { reclaimFreePagesStep } from '../sqlite/sqlite-free-page-reclaim' import { deleteSearchMessages } from './session-search-message-rows' export const RETENTION_DELETE_ROWS_PER_STEP = 256 -// Why in step with the deletes rather than one sweep at the end: `auto_vacuum = -// INCREMENTAL` holds every freed page until something asks for it back, and -// asking for a whole purge's worth at once is one long stall (40 ms per 22 MB -// freed, measured) instead of many short ones. -const RECLAIM_PAGES_PER_STEP = 2000 /** * Drops every file older than the cutoff, then hands its rows back in bounded @@ -91,12 +87,13 @@ export async function drainOrphanedMessages( db.exec('ROLLBACK') throw error } - db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`) + // In step with the deletes rather than one sweep at the end: many short stalls, not one long one. + reclaimFreePagesStep(db) if (deleted < RETENTION_DELETE_ROWS_PER_STEP) { orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id } await yieldStep() } // A `removeFile` frees its pages outside this loop and may leave none to drain. - db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`) + reclaimFreePagesStep(db) } 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 946cf08e1c41..a8fd4fd73f24 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. @@ -362,7 +363,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 e6f96ad5503d..f27905429f69 100644 --- a/src/main/codex/codex-structured-journal-goal-revision.test.ts +++ b/src/main/codex/codex-structured-journal-goal-revision.test.ts @@ -9,7 +9,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' @@ -89,7 +89,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' }, @@ -158,7 +158,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, () => ({})) prior.handle({ threadId: THREAD, method: 'thread/goal/updated', params: goalFrame() }) 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 117f204e9637..674e10b91f57 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 6fc1e88d1840..8edf50b94ae5 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-question-order.test.ts b/src/main/codex/codex-structured-question-order.test.ts index ff9a210e17ad..32d24e9bc7d1 100644 --- a/src/main/codex/codex-structured-question-order.test.ts +++ b/src/main/codex/codex-structured-question-order.test.ts @@ -31,6 +31,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' } @@ -90,7 +91,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/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 978f076d6e9e..94d05ed94be1 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 @@ -17,12 +17,13 @@ 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 +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -52,7 +53,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 @@ -60,11 +61,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() } } @@ -172,14 +173,14 @@ describe('a sequence gap', () => { 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. const writable = await open() await writable.appendItem(item(9), body('typed after the repair'), { fence: 1 }) 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: @@ -197,7 +198,7 @@ describe('a sequence gap', () => { 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 }) }) }) @@ -253,13 +254,13 @@ describe('a missing epoch row', () => { 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. const writable = await open() await writable.appendItem(item(1), body('typed after the repair'), { fence: 1 }) 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 6cc267a90547..f6c7c0955c7e 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 @@ -24,7 +24,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', @@ -62,7 +62,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..d356e770d488 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,15 @@ -// 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. `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). `status_json` is the chat's last settled listing status, +// trusted only where `status_seq` is still the tip of the live epoch. -/** 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 latches this build + * out, so every change stays additive. */ +export const JOURNAL_DB_SCHEMA_VERSION = 1 export function createJournalTablesSql(): string { return ` @@ -23,9 +22,11 @@ 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, + status_json TEXT, + status_seq INTEGER ); CREATE TABLE IF NOT EXISTS journal_repairs ( session_id TEXT PRIMARY KEY, 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..8942ad972325 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, + JournalDatabaseNewerSchemaError, 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 { - deleteAllJournalRows, deleteJournalRowSuffix, + deleteJournalSessionRows, insertJournalRow, - readJournalEpochRows, + iterateJournalEpochRows, + publishJournalSessionEpoch, readJournalRowsAfter, readJournalSessionEpoch, - upsertJournalSessionRow + readJournalTip } 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,47 @@ 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) 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_repairs', 'journal_rows', 'journal_sessions']) + ) + expect(db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(journalPragmaNumber(db, 'synchronous')).toBe(2) + 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 () => { + // T5: a newer build's database is refused and left byte-identical. + it('refuses a newer 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) + 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) - 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() - } finally { - latched.db.close() - } - expect((await stat(dbPath)).size).toBe(before.size) - expect(journalPragmaNumber(openJournalDatabase(dbPath).db, 'user_version')).toBe( - JOURNAL_DB_SCHEMA_VERSION + 5 - ) + expect(() => openJournalDatabase(dbPath)).toThrow(JournalDatabaseNewerSchemaError) + + expect(await digest(dbPath)).toBe(before) + await expect(stat(`${dbPath}-wal`)).rejects.toThrow() }) - // 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 +113,68 @@ 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, the tip, suffix truncation and a chat-scoped discard', () => { + const db = openJournalDatabase(dbPath) 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, 'epoch-other')) + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-other') 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(readJournalTip(db, 'session-1', 'epoch-1')).toBe(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. + deleteJournalSessionRows(db, 'session-1') + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) + expect(rowsOf(db, 'session-2', 'epoch-other')).toEqual([1]) } finally { - opened.db.close() + db.close() } }) it('refuses a duplicate sequence inside one epoch', () => { - const opened = openJournalDatabase(dbPath) + const db = openJournalDatabase(dbPath) 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)) + expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() + insertJournalRow(db, 'session-1', epochRow(1, 'epoch-2')) } finally { - opened.db.close() + db.close() } }) - it('upserts the session projection in place', () => { - const opened = openJournalDatabase(dbPath) + it('moves the epoch pointer in place and forgets the saved status with it', () => { + const db = openJournalDatabase(dbPath) 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') + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + db.exec("UPDATE journal_sessions SET status_json = '{}', status_seq = 3") + publishJournalSessionEpoch(db, SESSION, 'epoch-2') + expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-2') expect( - opened.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() - ).toMatchObject({ total: 1 }) + db + .prepare('SELECT count(*) AS total, max(status_json) AS status FROM journal_sessions') + .get() + ).toMatchObject({ total: 1, status: null }) } finally { - opened.db.close() + 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 ( 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 6f1cd60733b0..767978ebd036 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -1,46 +1,47 @@ -// 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' 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 -export type OpenJournalDatabase = { - db: Database.Database - /** A newer `user_version` was met: this build reads and never writes. */ - readOnly: boolean +/** A newer build wrote this database: this build neither reads nor writes it. */ +export class JournalDatabaseNewerSchemaError extends Error { + constructor( + readonly storedVersion: number, + dbPath: string + ) { + super( + `chat journal ${dbPath} uses schema ${storedVersion}; this build knows only up to ${JOURNAL_DB_SCHEMA_VERSION}` + ) + this.name = 'JournalDatabaseNewerSchemaError' + } } export function journalPragmaNumber(db: Database.Database, name: string): number { return Number(db.pragma(name, { simple: true }) ?? 0) } -export function openJournalDatabase(dbPath: string): OpenJournalDatabase { +export function openJournalDatabase(dbPath: string): Database.Database { const probe = new Database(dbPath) - let stored: number - try { - stored = journalPragmaNumber(probe, 'user_version') - } catch (error) { - probe.close() - throw error - } - if (stored > JOURNAL_DB_SCHEMA_VERSION) { - probe.close() - return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), readOnly: true } - } let transferred = false try { - configureJournalPragmas(probe) + const stored = journalPragmaNumber(probe, 'user_version') + if (stored > JOURNAL_DB_SCHEMA_VERSION) { + throw new JournalDatabaseNewerSchemaError(stored, dbPath) + } + configureJournalPragmas(probe, stored) createJournalSchema(probe, stored) hardenSqliteDatabaseFiles(dbPath) - const opened = { db: probe, readOnly: false } transferred = true - return opened + return probe } finally { if (!transferred) { probe.close() @@ -48,21 +49,24 @@ 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: it is what lets freed pages go back in + // bounded steps rather than a full VACUUM, which could never be switched on later. + 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. + // 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') + 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) { 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 96273366d5f0..2b4e93c2849f 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 @@ -1,19 +1,21 @@ // Republishing an epoch is ONE transaction. -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase, type OpenJournalDatabase } from './journal-database' +import { journalDatabasePath, 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 { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -25,7 +27,7 @@ const IDENTITY: AgentSessionJournalIdentity = { let root: string let clock = 1_000 -let database: OpenJournalDatabase +let database: JournalHostDatabase const journals = createTrackedJournalOpener() function now(): number { @@ -42,7 +44,7 @@ function replace(input: { onPublished?: (loaded: JournalLoad) => void }): void { replaceJournalEpoch({ - db: database.db, + database, identity: IDENTITY, reason: 'legacy_import', fence: 1, @@ -56,12 +58,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. } @@ -85,7 +87,7 @@ describe('journal epoch replacement', () => { }) it('discards every superseded row in the same transaction', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) await journal.appendItem(item(1), { kind: 'status', text: 'old' }, { fence: 1 }) await journal.appendItem(item(2), { kind: 'status', text: 'older' }, { fence: 1 }) const before = journal.epoch @@ -100,4 +102,27 @@ describe('journal epoch replacement', () => { { kind: 'status', text: 'republished' } ]) }) + + // `auto_vacuum = INCREMENTAL` only marks freed pages; nothing hands them back unless asked. A + // rewind that replaces a large chat must not leave the file holding its old history forever. + it('hands the pages a large replace freed back to the filesystem', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const text = 'x'.repeat(4_000) + for (let ordinal = 1; ordinal <= 400; ordinal += 1) { + await journal.appendItem(item(ordinal), { kind: 'status', text }, { fence: 1 }) + } + database.db.pragma('wal_checkpoint(TRUNCATE)') + const grown = (await stat(journalDatabasePath(root))).size + + await journal.replaceEpochItems('legacy_import', 1, [ + { identity: item(9), body: { kind: 'status', text: 'republished' } } + ]) + // The replace schedules the reclaim itself; this only waits for it. + await vi.waitFor( + () => expect(Number(database.db.pragma('freelist_count', { simple: true }))).toBeLessThan(8), + { timeout: 2_000 } + ) + database.db.pragma('wal_checkpoint(TRUNCATE)') + expect((await stat(journalDatabasePath(root))).size).toBeLessThan(grown / 4) + }) }) 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 9512d5e9fa1d..d4d738c8c920 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 @@ -2,22 +2,23 @@ // // 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. +// republished history is exactly what the marker was holding out for. The pages +// the old rows held go back to the filesystem after the commit. import type { AgentJournalItemBody, AgentJournalItemIdentity, 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, + deleteJournalSessionRows, insertJournalRow, - upsertJournalSessionRow + publishJournalSessionEpoch } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' import { assertJournalFence } from './journal-write-guards' @@ -29,7 +30,7 @@ export type JournalReplacementItem = { } export function replaceJournalEpoch(input: { - db: Database.Database + database: JournalHostDatabase identity: AgentSessionJournalIdentity reason: AgentJournalEpochReason fence: number @@ -63,23 +64,20 @@ 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) => { + deleteJournalSessionRows(db, sessionId) + 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 // live store writing into an epoch whose rows were just deleted. state.oldestSequence = 1 input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) + void input.database.reclaimFreePages() } 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..5ea2c8f9f845 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 @@ -3,25 +3,25 @@ // One transaction: discard every row of the superseded epoch, insert the new // epoch row at sequence 1, move the session projection onto it, and retire any // repair marker the superseded epoch was carrying. Superseded rows are DELETED -// rather than retained — nothing would ever shed them. +// rather than retained — nothing would ever shed them — and the pages they held +// go back to the filesystem after the commit. 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, + deleteJournalSessionRows, insertJournalRow, - upsertJournalSessionRow + publishJournalSessionEpoch } 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,23 +41,20 @@ 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) => { + deleteJournalSessionRows(db, sessionId) + 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 }) + void input.database.reclaimFreePages() } 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 d6424ce7952c..22b773c56dae 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 @@ -4,22 +4,22 @@ // `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 +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -38,15 +38,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 { @@ -71,7 +77,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') }) @@ -84,7 +90,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') }) @@ -93,7 +99,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 () => { @@ -130,7 +136,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, @@ -149,11 +155,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) } finally { - opened.db.close() + opened.close() } await writeRemnant() @@ -161,20 +167,36 @@ 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() - } + openTestJournalHostDatabase(root) + .db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + .run( + IDENTITY.sessionId, + epoch, + 2, + 1, + 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 592f7471c5ce..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-handle-ownership.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -// 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 }) - 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 }) - 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..1442908d3720 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -0,0 +1,77 @@ +// The host journal database for a test's state directory, opened the one way production opens it: +// under a held owner lock. 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 { tryAcquireJournalOwnerLock, type JournalOwnerLock } from './journal-owner-lock' +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.database.isClosed) { + return existing.database + } + const lock = existing?.lock.isHeld ? existing.lock : tryAcquireJournalOwnerLock(directory) + if (!lock) { + throw new Error(`another process owns the chat journal in ${directory}`) + } + const database = JournalHostDatabase.open(lock) + opened.set(directory, { lock, database }) + return database +} + +/** Closes every database this process opened for tests, then releases their locks. */ +export function closeTestJournalHostDatabases(): void { + for (const { lock, database } of opened.values()) { + database.close() + lock.release() + } + 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 and releases their locks. */ + 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) +} 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..1aa04d57b259 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -0,0 +1,137 @@ +// The one connection every chat's writes share: its transactions, its busy handling, and the order +// in which it closes at quit. + +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 +} from './journal-host-database-test-support' +import { readJournalEpochRows } from './journal-row-table' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'local', + 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 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 }) + ).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 })).rejects.toBe(busy) + vi.mocked(database.db.exec).mockImplementation(exec) + await expect(journal.appendItem(item(2), text('next'), { fence: 1 })).resolves.toBeDefined() + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([text('next')]) + }) + + 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 }) + } + } 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( + readJournalEpochRows(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..fca0095888f4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -0,0 +1,121 @@ +// The host's one chat journal database: every structured chat on this state directory, in one +// file, on one connection, opened only by the process holding the owner lock. +// +// 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 { setImmediate as yieldToEventLoop } from 'node:timers/promises' +import { join } from 'node:path' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import { reclaimFreePagesStep } from '../../sqlite/sqlite-free-page-reclaim' +import { openJournalDatabase } from './journal-database' +import type { JournalOwnerLock } from './journal-owner-lock' +import { journalDirectoryFor } from './journal-paths' +import { AgentSessionJournalError } from './journal-write-guards' + +export 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 + private reclaiming: Promise | null = null + + private constructor( + readonly stateDirectory: string, + connection: Database.Database + ) { + this.connection = connection + } + + /** Takes the lock as proof of ownership: no other path opens this file. */ + static open(lock: JournalOwnerLock): JournalHostDatabase { + if (!lock.isHeld) { + throw new Error('the chat journal opens only under a held owner lock') + } + const stateDirectory = lock.stateDirectory + return new JournalHostDatabase( + stateDirectory, + openJournalDatabase(journalDatabasePath(stateDirectory)) + ) + } + + get isClosed(): boolean { + return this.connection === null + } + + get db(): Database.Database { + if (!this.connection) { + throw new AgentSessionJournalError('journal_closed', 'the chat journal database is closed') + } + return this.connection + } + + /** One IMMEDIATE transaction. `run` is synchronous by contract: an await inside it would let + * another chat's statements land in this transaction on the shared connection. */ + transaction(run: (db: Database.Database) => T): T { + const db = this.db + db.exec('BEGIN IMMEDIATE') + let result: T + try { + result = run(db) + if (result instanceof Promise) { + throw new Error('a chat journal transaction must not await') + } + } catch (error) { + db.exec('ROLLBACK') + throw error + } + db.exec('COMMIT') + return result + } + + /** Where this chat's history lived before the journal was one database per host. */ + legacyDirectoryFor( + identity: Pick + ): string { + return journalDirectoryFor(this.stateDirectory, identity) + } + + /** + * Hands the pages a delete freed back to the filesystem, one bounded step per turn of the event + * loop, after the transaction that freed them. Called by everything that deletes rows. Passes + * coalesce: one already running picks up whatever the new delete freed. + */ + reclaimFreePages(): Promise { + this.reclaiming ??= this.runReclaim() + return this.reclaiming + } + + /** Last, after every store has drained. */ + close(): void { + const connection = this.connection + this.connection = null + connection?.close() + } + + private async runReclaim(): Promise { + try { + await yieldToEventLoop() + let previous = Number.POSITIVE_INFINITY + while (this.connection) { + const remaining = reclaimFreePagesStep(this.connection) + // A step that frees nothing (a file created without incremental auto-vacuum) ends the pass. + if (remaining === 0 || remaining >= previous) { + return + } + previous = remaining + await yieldToEventLoop() + } + } catch (error) { + // Bookkeeping: a failed step only leaves pages for the next delete's pass. + console.warn('[agent-session-journal] reclaiming freed pages failed', error) + } finally { + // Cleared as the pass ends, not a microtask later, so a delete right after starts a new one. + this.reclaiming = null + } + } +} 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 92b085e2e0fd..8f72df624d40 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 @@ -18,6 +18,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 } => ({ @@ -72,7 +73,7 @@ async function open( ): Promise { return openAgentSessionJournal({ identity: identity(agent, sessionId), - journalDir: root, + database: openTestJournalHostDatabase(root), now: tick, mintEpoch: () => `epoch-${clock}`, ...overrides @@ -477,7 +478,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 @@ -501,7 +504,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, @@ -629,7 +634,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', [ { @@ -675,7 +680,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', [ { @@ -703,7 +708,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 d2e1bf3b3501..e6b948051924 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 @@ -4,8 +4,8 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure } from './journal-open-failure' -import { loadJournal } from './journal-open' -import { journalDatabaseFile } from './journal-paths' +import { journalDatabasePath } from './journal-host-database' +import { replayJournal } from './journal-open' let root: string @@ -17,10 +17,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)) + try { + replayJournal(db, 'session-1') + } finally { + db.close() + } } catch (error) { return error } @@ -38,17 +43,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 path = journalDatabasePath(root) const opened = openJournalDatabase(path) - opened.db.exec('PRAGMA journal_mode = DELETE') - opened.db.close() + 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) 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 1835f691cb72..b70226ea8238 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 @@ -2,7 +2,15 @@ // 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, + refuse, + type AgentSessionRefusalError, + type AgentSessionWireRefusal +} from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' +import { JournalDatabaseNewerSchemaError } from './journal-database' export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> @@ -20,3 +28,34 @@ 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']) +} + +export const JOURNAL_NEWER_SCHEMA_MESSAGE = + 'Chats were saved by a newer Orca. Update Orca to keep using them.' + +/** Why a journal open failed, and the words a released client prints for it. */ +function journalOpenFailureWords(error: unknown): { reason: JournalOpenFailure; message: string } { + if (error instanceof JournalDatabaseNewerSchemaError) { + // Clears when this Orca is updated, so it is not damage. + return { reason: 'journalUnavailable', message: JOURNAL_NEWER_SCHEMA_MESSAGE } + } + const reason = classifyJournalOpenFailure(error) + return { reason, message: JOURNAL_OPEN_MESSAGE[reason] } +} + +/** The refusal a failed journal open answers with, as the wire carries it. */ +export function journalOpenRefusal(error: unknown): AgentSessionWireRefusal { + 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 { + const { reason, message } = journalOpenFailureWords(error) + return agentSessionRefusalError('agent_session_journal_unreadable', { reason }, message) +} 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..595dcec6d691 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,18 +38,8 @@ 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) { return null @@ -140,27 +127,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-owner-lock-test-support.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock-test-support.ts new file mode 100644 index 000000000000..dba479760306 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-owner-lock-test-support.ts @@ -0,0 +1,81 @@ +// Another process on the same state directory, for tests of the owner lock. The lock is a kernel +// lock held per process, so only a real second process can stand in for a second Orca. + +import { join } from 'node:path' +import { runProcess, spawnProcess } from '../../../shared/child-process/run-process' +import { JOURNAL_OWNER_LOCK_FILE } from './journal-owner-lock' + +const HOLD_SCRIPT = ` +const { DatabaseSync } = require('node:sqlite') +const db = new DatabaseSync(process.argv[1], { timeout: 0 }) +db.exec('BEGIN EXCLUSIVE') +process.stdout.write('held\\n') +setInterval(() => {}, 1000) +` + +const PROBE_SCRIPT = ` +const { DatabaseSync } = require('node:sqlite') +const db = new DatabaseSync(process.argv[1], { timeout: 0 }) +try { + db.exec('BEGIN EXCLUSIVE') + process.stdout.write('acquired') +} catch { + process.stdout.write('refused') +} +` + +export type JournalOwnerLockHolder = { + pid: number + /** SIGKILL, the way a crashed Orca dies; resolves once the process is gone. */ + kill: () => Promise +} + +/** A second process holding the state directory's owner lock until it is killed. */ +export async function holdJournalOwnerLockInChild( + stateDirectory: string +): Promise { + const child = spawnProcess({ + program: process.execPath, + args: ['-e', HOLD_SCRIPT, join(stateDirectory, JOURNAL_OWNER_LOCK_FILE)], + // Bounded: a test that never kills it still does not leave it running. + timeoutMs: 60_000 + }) + const pid = child.pid + if (pid === undefined) { + throw new Error('the lock holder did not start') + } + const exited = new Promise((resolve) => child.once('exit', () => resolve())) + await new Promise((resolve, reject) => { + let output = '' + child.stdout.on('data', (chunk: Buffer) => { + output += chunk.toString('utf8') + if (output.includes('held')) { + resolve() + } + }) + child.once('exit', (code) => reject(new Error(`the lock holder exited early (${code})`))) + }) + return { + pid, + kill: async () => { + // Only the process this helper started, by its own handle. + child.kill('SIGKILL') + await exited + } + } +} + +/** Whether a separate process could take the owner lock right now. It releases it at once. */ +export async function probeJournalOwnerLockInChild( + stateDirectory: string +): Promise<'acquired' | 'refused'> { + const result = await runProcess({ + program: process.execPath, + args: ['-e', PROBE_SCRIPT, join(stateDirectory, JOURNAL_OWNER_LOCK_FILE)], + timeoutMs: 30_000 + }) + if (result.stdout === 'acquired' || result.stdout === 'refused') { + return result.stdout + } + throw new Error(`the lock probe failed: ${result.stderr}`) +} diff --git a/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts new file mode 100644 index 000000000000..df5705c79b05 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts @@ -0,0 +1,139 @@ +// The owner lock: one process per state directory opens the chat journal. + +import { existsSync } from 'node:fs' +import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { sweepOrphanedRuntimeSockets } from '../../runtime/runtime-rpc/runtime-rpc-socket-metadata' +import { copyProfileStateRecoveryFile } from '../../persistence/profile-state/profile-state-recovery-copy' +import { JournalHostDatabase, journalDatabasePath } from './journal-host-database' +import { + JOURNAL_OWNER_LOCK_FILE, + retryJournalOwnerLock, + tryAcquireJournalOwnerLock, + type JournalOwnerLock +} from './journal-owner-lock' +import { + holdJournalOwnerLockInChild, + probeJournalOwnerLockInChild, + type JournalOwnerLockHolder +} from './journal-owner-lock-test-support' + +let root: string +let holder: JournalOwnerLockHolder | null = null +const locks: JournalOwnerLock[] = [] + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-owner-')) +}) + +afterEach(async () => { + await holder?.kill() + holder = null + for (const lock of locks.splice(0)) { + lock.release() + } + await rm(root, { recursive: true, force: true }) +}) + +function acquire(): JournalOwnerLock | null { + const lock = tryAcquireJournalOwnerLock(root) + if (lock) { + locks.push(lock) + } + return lock +} + +describe('the owner lock', () => { + // T-B1: a second process is refused, never opens the database, and takes over once the owner + // dies — with no stale lock to clean up. + it('refuses a second process until the owner dies, then grants it at once', async () => { + holder = await holdJournalOwnerLockInChild(root) + + expect(acquire()).toBeNull() + expect(existsSync(journalDatabasePath(root))).toBe(false) + + await holder.kill() + holder = null + const lock = acquire() + expect(lock?.isHeld).toBe(true) + // The owner can now open the one database; nobody could before. + JournalHostDatabase.open(lock!).close() + }) + + it('keeps the lock file empty and never grows it a write-ahead log', async () => { + const lock = acquire() + expect(lock).not.toBeNull() + const path = join(root, JOURNAL_OWNER_LOCK_FILE) + expect((await stat(path)).size).toBe(0) + expect(await readdir(root)).not.toContain(`${JOURNAL_OWNER_LOCK_FILE}-wal`) + expect(await probeJournalOwnerLockInChild(root)).toBe('refused') + }) + + it('opens the database only under a lock it still holds', () => { + const lock = acquire()! + lock.release() + expect(() => JournalHostDatabase.open(lock)).toThrow('owner lock') + expect(existsSync(journalDatabasePath(root))).toBe(false) + }) + + // T-owner-copy (N-R1): a POSIX close of ANY descriptor on the lock's inode drops the whole + // process's lock. Nothing that walks or copies userData in the owning process may open it. + it('survives every walk of the state directory the owning process makes', async () => { + expect(acquire()).not.toBeNull() + await writeFile(join(root, 'o-999999-stale.sock'), '') + await writeFile(join(root, 'orca-data.json'), '{}') + + sweepOrphanedRuntimeSockets(root, process.pid) + copyProfileStateRecoveryFile(join(root, 'orca-data.json'), join(root, 'orca-data.json.copy')) + + expect(await probeJournalOwnerLockInChild(root)).toBe('refused') + }) +}) + +describe('retrying a refused lock', () => { + it('backs off from one second to thirty and stops once it is granted', () => { + const delays: number[] = [] + const pending: (() => void)[] = [] + let grantOn = 7 + const granted: JournalOwnerLock[] = [] + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retry only hands the value it was given to `onAcquired`. + const fakeLock = { isHeld: true } as unknown as JournalOwnerLock + retryJournalOwnerLock({ + stateDirectory: root, + acquire: () => (--grantOn === 0 ? fakeLock : null), + onAcquired: (lock) => granted.push(lock), + schedule: (run, delayMs) => { + delays.push(delayMs) + pending.push(run) + return { cancel: () => undefined } + } + }) + while (pending.length > 0) { + pending.shift()!() + } + expect(delays).toEqual([1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000]) + expect(granted).toEqual([fakeLock]) + }) + + it('dies when cancelled', () => { + const pending: (() => void)[] = [] + let attempts = 0 + const retry = retryJournalOwnerLock({ + stateDirectory: root, + acquire: () => { + attempts += 1 + return null + }, + onAcquired: () => undefined, + schedule: (run) => { + pending.push(run) + return { cancel: () => pending.splice(0) } + } + }) + retry.cancel() + expect(pending).toEqual([]) + expect(attempts).toBe(0) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-owner-lock.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock.ts new file mode 100644 index 000000000000..4b8bb75e6676 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-owner-lock.ts @@ -0,0 +1,122 @@ +// The one process allowed to open this state directory's chat journal. +// +// Two Orca processes on one profile corrupt each other's chats: accepting a send and opening a +// chat both write without a lease, and each process mints sequence numbers from its own fold. +// Dev desktops skip the single-instance lock and share one profile, and a packaged build's lock +// lives in a `$TMPDIR` socket macOS purges (#7848). So the journal carries its own lock. +// +// The lock is a held `BEGIN EXCLUSIVE` on an empty SQLite file: a kernel byte-range lock that is +// refused while another process holds it and released when the holder dies, so there is no stale +// lock to clean up and no timeout to guess. The holder never writes, so the file stays empty. +// +// INVARIANT: in the owning process nothing but this connection may open or close a descriptor on +// the `.owner` file. A POSIX close of ANY descriptor on that inode drops every lock the process +// holds on it, so a copier that reads the file would silently hand ownership away. + +import { mkdirSync } from 'node:fs' +import { join } from 'node:path' +import Database from '../../sqlite/sync-database' +import { isTransientSqliteContention } from '../../sqlite/sqlite-read-failure' + +export const JOURNAL_OWNER_LOCK_FILE = 'agent-session-journal.owner' + +export class JournalOwnerLock { + private held: Database | null + + constructor( + readonly stateDirectory: string, + connection: Database + ) { + this.held = connection + } + + get isHeld(): boolean { + return this.held !== null + } + + /** Last, after the journal database is closed. Idempotent. */ + release(): void { + const held = this.held + this.held = null + if (!held) { + return + } + try { + held.exec('ROLLBACK') + } finally { + held.close() + } + } +} + +const RETRY_FIRST_DELAY_MS = 1_000 +const RETRY_MAX_DELAY_MS = 30_000 + +/** + * Keeps asking for the lock a peer holds, backing off from 1 s to 30 s. On Windows a dead + * holder's lock is released only after an OS-timed delay, so one refusal is not the answer. + * In memory only: it dies with the process or when cancelled. + */ +export function retryJournalOwnerLock(input: { + stateDirectory: string + onAcquired: (lock: JournalOwnerLock) => void + onError?: (error: unknown) => void + acquire?: (stateDirectory: string) => JournalOwnerLock | null + schedule?: (run: () => void, delayMs: number) => { cancel: () => void } +}): { cancel: () => void } { + const acquire = input.acquire ?? tryAcquireJournalOwnerLock + const schedule = input.schedule ?? scheduleUnrefTimer + let delayMs = RETRY_FIRST_DELAY_MS + let cancelled = false + let pending: { cancel: () => void } | null = null + const attempt = (): void => { + pending = null + if (cancelled) { + return + } + let lock: JournalOwnerLock | null = null + try { + lock = acquire(input.stateDirectory) + } catch (error) { + input.onError?.(error) + } + if (lock) { + input.onAcquired(lock) + return + } + delayMs = Math.min(delayMs * 2, RETRY_MAX_DELAY_MS) + pending = schedule(attempt, delayMs) + } + pending = schedule(attempt, delayMs) + return { + cancel: () => { + cancelled = true + pending?.cancel() + pending = null + } + } +} + +function scheduleUnrefTimer(run: () => void, delayMs: number): { cancel: () => void } { + const timer = setTimeout(run, delayMs) + timer.unref?.() + return { cancel: () => clearTimeout(timer) } +} + +/** The lock, or null while another process holds it. Any other failure throws. */ +export function tryAcquireJournalOwnerLock(stateDirectory: string): JournalOwnerLock | null { + mkdirSync(stateDirectory, { recursive: true }) + // Why no pragma: the holder runs one statement, so it never sets `journal_mode` and can never + // grow a `-wal` beside an empty file. `timeout: 0` makes a held lock refuse at once. + const connection = new Database(join(stateDirectory, JOURNAL_OWNER_LOCK_FILE), { timeout: 0 }) + try { + connection.exec('BEGIN EXCLUSIVE') + return new JournalOwnerLock(stateDirectory, connection) + } catch (error) { + connection.close() + if (isTransientSqliteContention(error)) { + return null + } + throw error + } +} 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..7cf6df4471b1 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,16 @@ -// 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 reads a file it +// finds here once, on that chat's first open, and the pre-SQLite format remnant check looks here. +// 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 +28,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' +export 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..ec5cce3c1f52 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.test.ts @@ -0,0 +1,233 @@ +// A chat's per-chat journal file from an earlier build is copied into the host's one database on +// that chat's first open: verbatim, once, and retired only after the copy commits. + +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, 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 Database from '../../sqlite/sync-database' +import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' +import { importPerSessionJournal } from './journal-per-session-import' +import { readJournalEpochRows, type JournalStoredRow } from './journal-row-table' + +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(): Promise<{ epoch: string; rows: JournalStoredRow[] }> { + const scratch = join(root, 'scratch') + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: scratch, + now: () => (clock += 1), + mintEpoch: () => 'epoch-from-the-earlier-build' + }) + 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: 'On it.' }] }, + { fence: 1 } + ) + const rows = readJournalEpochRows( + 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() + } +} + +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 journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('importing a per-chat journal', () => { + it('copies the history verbatim on first open and retires 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( + readJournalEpochRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) + ).toEqual(rows) + expect(existsSync(legacyDir())).toBe(false) + expect(existsSync(`${legacyDir()}.imported`)).toBe(true) + }) + + // 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) + }) + + // T-R2B1: the copy committed and only the rename failed. Rows appended since, a restart, and a + // reopen with the file still there: nothing is imported again, and nothing is lost. + it('never imports again after a failed rename, across a restart', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + // A non-empty directory where the retired one goes: every rename fails, as a held handle would. + await mkdir(join(`${legacyDir()}.imported`, 'blocker'), { recursive: true }) + 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 } + ) + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) + // The process exits: the database closes and the owner lock goes with it. + await journals.closeAll() + + const reopened = await openChat() + expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 }) + expect(JSON.stringify(reopened.snapshot().items)).toContain('after the upgrade') + + // The next open retries only the rename, which now succeeds. + await rm(`${legacyDir()}.imported`, { recursive: true }) + await journals.closeAll() + await openChat() + expect(existsSync(legacyDir())).toBe(false) + }) + + // 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 }) + + expect(() => + importPerSessionJournal({ + database, + identity: IDENTITY, + legacyDirectory: legacyDir(), + openSource: () => { + throw io + } + }) + ).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() + expect(existsSync(legacyDir())).toBe(false) + }) +}) 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..2d1e5b6032cb --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-import.ts @@ -0,0 +1,158 @@ +// Copying a chat's per-chat journal file into the host's one database, on that chat's first 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. +// +// The rename that retires the source runs only after the copy commits. 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 { existsSync, renameSync } from 'node:fs' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import Database from '../../sqlite/sync-database' +import type { SqliteRow } from '../../sqlite/sqlite-statement' +import type { JournalHostDatabase } from './journal-host-database' +import { legacyJournalDatabaseFile } from './journal-paths' +import { publishJournalSessionEpoch, readJournalSessionEpoch } from './journal-row-table' + +/** The newest per-chat file shape any build wrote. */ +const LEGACY_JOURNAL_SCHEMA_VERSION = 2 +const LEGACY_ROW_PAGE_SIZE = 512 + +const SELECT_LEGACY_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' +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_REPAIRS = + "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'journal_repairs'" +const SELECT_LEGACY_REPAIR = + 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?' +const INSERT_ROW = + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' +const DELETE_SESSION_ROWS = 'DELETE FROM journal_rows WHERE session_id = ?' +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` + +export type PerSessionJournalImportDeps = { + openSource?: (path: string) => Database.Database + rename?: (from: string, to: string) => void +} + +export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' + +/** The name a copied directory is moved to, so nothing reads it again. */ +export function importedLegacyJournalDirectory(legacyDirectory: string): string { + return `${legacyDirectory}.imported` +} + +export function importPerSessionJournal( + input: { + database: JournalHostDatabase + identity: AgentSessionJournalIdentity + legacyDirectory: string + } & PerSessionJournalImportDeps +): PerSessionJournalImportOutcome { + const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) + if (!existsSync(sourcePath)) { + return 'absent' + } + // Import once: a chat that already has a journal here was copied, and only its rename failed. + if (readJournalSessionEpoch(input.database.db, input.identity.sessionId) !== null) { + retireImportedDirectory(input) + return 'already-imported' + } + const source = (input.openSource ?? openLegacySource)(sourcePath) + let copied: boolean + try { + copied = copyLegacyJournal(input, source) + } finally { + source.close() + } + if (!copied) { + // Never written. Left in place for this open, whose empty chat may still owe the notice about + // a pre-SQLite transcript beside it; the next open finds the chat founded and retires it. + return 'absent' + } + retireImportedDirectory(input) + return 'imported' +} + +/** A plain read-only connection: it sees committed WAL frames without checkpointing them. */ +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 + } +} + +function copyLegacyJournal( + input: { database: JournalHostDatabase; identity: AgentSessionJournalIdentity }, + source: Database.Database +): boolean { + const { sessionId } = input.identity + const epoch = source.prepare(SELECT_LEGACY_EPOCH).get(sessionId)?.epoch + if (typeof epoch !== 'string' || epoch.length === 0) { + return false + } + const repair = readLegacyRepair(source, sessionId) + input.database.transaction((db) => { + db.prepare(DELETE_SESSION_ROWS).run(sessionId) + const insert = db.prepare(INSERT_ROW) + let afterSeq = Number.MIN_SAFE_INTEGER + for (;;) { + const page = source + .prepare(SELECT_LEGACY_ROWS) + .all(sessionId, epoch, afterSeq, LEGACY_ROW_PAGE_SIZE) + for (const row of page) { + // Copied as stored: the bytes are the row, its epoch and sequence included. + insert.run(sessionId, epoch, row.seq, row.ts, row.row_json) + } + const last = page.at(-1)?.seq + if (page.length < LEGACY_ROW_PAGE_SIZE || typeof last !== 'number') { + break + } + afterSeq = last + } + publishJournalSessionEpoch(db, input.identity, epoch) + if (repair) { + db.prepare(UPSERT_REPAIR).run( + sessionId, + repair.epoch, + repair.content_from, + repair.repaired_at + ) + } + }) + return true +} + +/** The per-chat repair marker, as stored; v1 files predate the table. */ +function readLegacyRepair(source: Database.Database, sessionId: string): SqliteRow | null { + if (!source.prepare(HAS_LEGACY_REPAIRS).get()) { + return null + } + return source.prepare(SELECT_LEGACY_REPAIR).get(sessionId) ?? null +} + +/** Best effort: the copy is committed, so a failed rename only leaves a file the next open skips. */ +function retireImportedDirectory(input: { + legacyDirectory: string + rename?: (from: string, to: string) => void +}): void { + const target = importedLegacyJournalDirectory(input.legacyDirectory) + try { + ;(input.rename ?? renameSync)(input.legacyDirectory, target) + } catch (error) { + console.warn(`[agent-session-journal] retiring imported ${input.legacyDirectory} failed`, error) + } +} 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 07ae011b7908..de699d671e0d 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 @@ -14,7 +14,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 @@ -165,7 +165,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-repair-marker.ts b/src/main/native-chat/agent-session-journal/journal-repair-marker.ts index f9ef09e688de..747d273f5b15 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,11 @@ 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 deleted - } catch (error) { - input.db.exec('ROLLBACK') - throw error - } + const deleted = input.database.transaction((db) => { + const count = deleteJournalRowSuffix(db, input.sessionId, input.epoch, input.fromSeq) + db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now) + return count + }) + void input.database.reclaimFreePages() + return deleted } 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 4428e13953e1..6c9f91a53058 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 @@ -14,7 +14,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' @@ -48,7 +48,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 fe23a5ddcb5f..c95ecb9e9857 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 @@ -11,10 +11,11 @@ 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 +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -61,7 +62,7 @@ const journals = createTrackedJournalOpener() const open = () => journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: () => ++clock, mintEpoch: () => 'epoch-1' }) @@ -111,7 +112,7 @@ describe('context facts on replayed turn rows', () => { { fence: 1 } ) 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') @@ -124,7 +125,7 @@ describe('context facts on replayed turn rows', () => { .prepare('UPDATE journal_rows SET row_json = ? WHERE seq = 2') .run(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 b764ebd5ec93..b3bd8fa88c24 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 @@ -3,9 +3,10 @@ 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 +} 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. @@ -33,7 +34,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( @@ -47,7 +48,7 @@ describe('journal row schema versions', () => { { fence: 1 } ) 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') @@ -60,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..a6d812a31b4e 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,9 +1,7 @@ // 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. +// Each one is a prefix or range scan of one chat's rows on the primary key. 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' @@ -11,30 +9,33 @@ 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) +// A new epoch invalidates the saved status, which was computed at a position of the old one. +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, + status_json = NULL, status_seq = NULL` 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 WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` +const DELETE_SESSION_ROWS = 'DELETE FROM journal_rows WHERE session_id = ?' const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?' +const SELECT_TIP = 'SELECT max(seq) AS tip FROM journal_rows WHERE session_id = ? AND epoch = ?' 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 } -export function upsertJournalSessionRow( +/** Points the chat at `epoch`. Only an epoch change writes this row; an append never does. */ +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,12 +48,10 @@ 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)) +/** The live epoch's highest sequence, or 0 when it holds no row. */ +export function readJournalTip(db: Database.Database, sessionId: string, epoch: string): number { + const tip = db.prepare(SELECT_TIP).get(sessionId, epoch)?.tip + return typeof tip === 'number' ? tip : 0 } // Why pages, not `.iterate()`: a lazily consumed cursor pins a read snapshot for as long as the @@ -78,6 +77,15 @@ export function* iterateJournalEpochRows( } } +/** Every row of one epoch, in sequence order. */ +export function readJournalEpochRows( + db: Database.Database, + sessionId: string, + epoch: string +): JournalStoredRow[] { + return [...iterateJournalEpochRows(db, sessionId, epoch)] +} + export function readJournalRowsAfter( db: Database.Database, sessionId: string, @@ -93,14 +101,9 @@ export function readJournalRowsAfter( return toStoredRows(db.prepare(SELECT_ROWS_AFTER).all(sessionId, epoch, afterSeq)) } -/** - * 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 the chat holds, whatever its epoch: a new epoch replaces them all. */ +export function deleteJournalSessionRows(db: Database.Database, sessionId: string): void { + db.prepare(DELETE_SESSION_ROWS).run(sessionId) } /** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */ 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..42c58b4b3534 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 type { JournalHostDatabase } from './journal-host-database' import { insertJournalRow, readJournalEpochRows, - upsertJournalSessionRow + publishJournalSessionEpoch } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import { JournalRowWriter } from './journal-row-writer' +import { openTestJournalHostDatabase } 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) + publishJournalSessionEpoch(database.db, { sessionId: SESSION_ID, workspaceId: 'ws-1' }, EPOCH) readOnly = false }) afterEach(async () => { try { - database.db.close() + database.close() } catch { // Already closed by the case. } 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 85ff7da7a3f2..ac920059cd09 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,5 @@ -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 { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' @@ -7,7 +7,7 @@ export type JournalRowWriterDeps = { sessionId: string now: () => number serialize: (run: () => Promise) => Promise - database: () => { db: Database.Database } + database: () => JournalHostDatabase readOnly: () => boolean highestFence: () => number nextSequence: () => number @@ -22,16 +22,8 @@ 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) - db.exec('COMMIT') - } catch (error) { - db.exec('ROLLBACK') - throw error - } + // One INSERT: the chat's epoch pointer moves only when the epoch does. + this.deps.database().transaction((db) => insertJournalRow(db, this.deps.sessionId, row)) // COMMIT landed, so the row is durable: adopt it before anything that can // fail. Rejecting here instead would leave the next append reusing a // sequence the table already holds. diff --git a/src/main/native-chat/agent-session-journal/journal-session-status.test.ts b/src/main/native-chat/agent-session-journal/journal-session-status.test.ts new file mode 100644 index 000000000000..49d5d561f705 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-session-status.test.ts @@ -0,0 +1,114 @@ +// T-status: the saved listing status is keyed by the position it was computed at, so a reader can +// trust it only where the journal still stands exactly there — and never across an epoch change. + +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 { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionStatusProjection } from '../../../shared/structured-agent-session-projection' +import type Database from '../../sqlite/sync-database' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { writeJournalSessionStatus } from './journal-session-status' + +const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } +} + +const IDLE: StructuredAgentSessionStatusProjection = { + status: 'idle', + latestPrompt: 'add a retry', + lastAssistantMessage: 'Done.' +} + +let root: string +const journals = createTrackedJournalOpener() + +/** The one join a restarted host lists from: a saved status stands only at its own tip. */ +function trustedStatuses(db: Database.Database): { session_id: string; status_json: string }[] { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the columns are named in the statement. + return db + .prepare( + `SELECT s.session_id, s.status_json FROM journal_sessions s + WHERE s.status_json IS NOT NULL AND s.status_seq = ( + SELECT max(r.seq) FROM journal_rows r WHERE r.session_id = s.session_id AND r.epoch = s.epoch)` + ) + .all() as { session_id: string; status_json: string }[] +} + +async function settled(): Promise { + // The write joins the chat's queue behind the rows it describes. + await new Promise((resolve) => setImmediate(resolve)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-status-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +describe('the saved listing status', () => { + it('is saved at the tip it was computed at, and trusted only there', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const { db } = openTestJournalHostDatabase(root) + + journal.saveListingStatus(IDLE) + await settled() + expect(trustedStatuses(db)).toHaveLength(1) + expect(JSON.parse(trustedStatuses(db)[0]!.status_json)).toMatchObject({ + v: 1, + projection: IDLE, + lastActivityAt: journal.lastActivityAt() + }) + + // A row the status was not computed at: a miss, never a stale display. + await journal.appendItem( + { provider: 'orca', clientMessageId: 'note' }, + { kind: 'status', text: 'later' }, + { fence: 0 } + ) + expect(trustedStatuses(db)).toEqual([]) + }) + + it('is written once per change, not once per row', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const { db } = openTestJournalHostDatabase(root) + const prepare = vi.spyOn(db, 'prepare') + + journal.saveListingStatus(IDLE) + journal.saveListingStatus({ ...IDLE }) + await settled() + + expect( + prepare.mock.calls.filter(([sql]) => sql.startsWith('UPDATE journal_sessions')) + ).toHaveLength(1) + }) + + // The epoch case: a new epoch clears the saved status, and a write computed before the change + // cannot land on the new epoch even where its sequence matches the new tip. + it('never survives an epoch change, whatever the old position says', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const { db } = openTestJournalHostDatabase(root) + journal.saveListingStatus(IDLE) + await settled() + const before = journal.cursor() + + await journal.rollEpoch('handle_forked', 1) + expect(journal.cursor().sequence).toBe(before.sequence) + expect(trustedStatuses(db)).toEqual([]) + + writeJournalSessionStatus(db, IDENTITY.sessionId, before, JSON.stringify({ stale: true })) + expect(trustedStatuses(db)).toEqual([]) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-session-status.ts b/src/main/native-chat/agent-session-journal/journal-session-status.ts new file mode 100644 index 000000000000..bedcf210b14b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-session-status.ts @@ -0,0 +1,18 @@ +// The listing status column: written after the rows it describes commit, keyed by the cursor it +// was computed at. The epoch guard makes a write computed before an epoch change a no-op, and +// every epoch change clears the column (journal-row-table.ts), so no old position survives one. + +import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' + +const WRITE_STATUS = `UPDATE journal_sessions SET status_json = ?, status_seq = ? +WHERE session_id = ? AND epoch = ?` + +export function writeJournalSessionStatus( + db: Database.Database, + sessionId: string, + cursor: AgentJournalCursor, + statusJson: string +): void { + db.prepare(WRITE_STATUS).run(statusJson, cursor.sequence, sessionId, cursor.epoch) +} 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 af53cd16d3db..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-store-close.test.ts +++ /dev/null @@ -1,270 +0,0 @@ -// `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 }) - 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 }) - - // 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 })), - 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') }] - }) - ), - 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 }) - 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 })).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 }) - 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 })).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 a4ff455b31fc..6cd44eebbe1e 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' @@ -20,19 +20,20 @@ import type { AgentSessionJournal } from './journal-store' export type JournalStoreHost = { 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 + 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 c2210efb253b..48d4b280e081 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 @@ -8,17 +8,15 @@ import type { AgentJournalResetReason, 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 } 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 fc69dd339d8b..f93e09f6b974 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 @@ -11,21 +11,28 @@ import { replayJournal } from './journal-open' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' import { deleteJournalRepairedSuffix } from './journal-repair-marker' +import { importPerSessionJournal } from './journal-per-session-import' export function restoreJournalStore( host: JournalStoreHost, collaborators: { epochController: JournalEpochController } ): Promise { 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) + // A per-chat file left by an earlier build is this chat's newest history: copied in first. + importPerSessionJournal({ + database: host.database(), + identity: host.identity, + legacyDirectory: host.legacyDirectory + }) + const loaded = 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, 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 df34ef73e736..216352387fd1 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 @@ -15,11 +15,14 @@ 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 { JournalDatabaseNewerSchemaError } from './journal-database' +import { journalDatabasePath } from './journal-host-database' import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -49,18 +52,18 @@ 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() } } @@ -73,6 +76,21 @@ async function appendRawRow(epoch: string, seq: number, rowJson: string): Promis }) } +/** 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' } + }) +} + beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-schema-')) clock = 1_000 @@ -84,30 +102,24 @@ 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 is not opened at all, so nothing here can write to it. + it('refuses a database a newer build stamped, and writes nothing', async () => { const journal = await open() await journal.appendItem(item(0), body('a'), { fence: 1 }) await journal.close() await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) - const before = await stat(journalDatabaseFile(root)) + const before = await stat(journalDatabasePath(root)) - const reopened = await open() - expect(reopened.isReadOnly).toBe(true) - await expect(reopened.appendItem(item(1), body('b'), { fence: 1 })).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) - await withDatabase((db) => { - expect(db.pragma('user_version', { simple: true })).toBe(JOURNAL_DB_SCHEMA_VERSION + 1) - }) + await expect(open()).rejects.toBeInstanceOf(JournalDatabaseNewerSchemaError) + expect((await stat(journalDatabasePath(root))).size).toBe(before.size) }) - 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 @@ -141,21 +153,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) 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 3637f03dab6c..d8fdfbcf0c46 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 @@ -12,17 +12,19 @@ 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 +} from './journal-host-database-test-support' import type Database from '../../sqlite/sync-database' const IDENTITY: AgentSessionJournalIdentity = { @@ -54,7 +56,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 @@ -203,17 +205,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 }) - 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'), { fence: 1 }) @@ -395,10 +386,11 @@ 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 }) - 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() @@ -413,15 +405,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 45c42b45f8ad..40f302a07d8d 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' @@ -17,16 +18,19 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import { currentAgentSessionThreadGoalBySequence } from '../../../shared/agent-session-thread-goal' import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage' +import type { StructuredAgentSessionStatusProjection } from '../../../shared/structured-agent-session-projection' +import { + STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + type StructuredAgentSessionSavedStatus +} from '../../../shared/structured-agent-session-saved-status' import { activeStructuredAgentSessionTurnIdBySequence, 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, @@ -55,33 +59,33 @@ import type { ResolveDispatchInput } from './journal-store-contracts' import type { AgentJournalEpochReason, JournalRow } 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' +import { writeJournalSessionStatus } from './journal-session-status' +import { AgentSessionJournalError } from './journal-write-guards' 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 + /** The epoch and projection last saved, so an unchanged status is not written per row. */ + private savedListingStatus: string | null = null private readonly queue: JournalWriteQueue - private readonly closer: JournalConnectionCloser private readonly rowWriter: JournalRowWriter private readonly epochController: JournalEpochController private readonly itemAppender: JournalItemAppender @@ -90,25 +94,19 @@ 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(), + database: () => this.database, state: () => this.state, readOnly: () => this.readOnly, setReadOnly: (readOnly) => { @@ -123,7 +121,9 @@ export class AgentSessionJournal { applyJournalRow(this.state, row) this.onCommitted?.() }, - loaded: () => this.loaded, + setOpenedCorrupt: (corrupt) => { + this.openedCorrupt = corrupt + }, malformedRows: () => this.malformedRows, setMalformedRows: (count) => { this.malformedRows = count @@ -146,10 +146,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 { @@ -165,27 +161,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 @@ -194,6 +184,42 @@ export class AgentSessionJournal { this.onCommitted = listener } + /** + * Saves the settled listing status at the fold's position, after the rows it describes have + * committed. Written only when the epoch or the projection changes, so about once per turn end. + * Bookkeeping: a failed write costs a later boot a miss, never a user action. + */ + saveListingStatus(projection: StructuredAgentSessionStatusProjection): void { + const cursor = this.cursor() + const key = `${cursor.epoch}\n${JSON.stringify(projection)}` + if (this.readOnly || key === this.savedListingStatus) { + return + } + const saved: StructuredAgentSessionSavedStatus = { + v: STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + projection, + lastActivityAt: this.state.lastActivityAt + } + this.savedListingStatus = key + this.queue + .serialize(async () => + writeJournalSessionStatus( + this.database.db, + this.identity.sessionId, + cursor, + JSON.stringify(saved) + ) + ) + .catch((error: unknown) => { + if (this.savedListingStatus === key) { + this.savedListingStatus = null + } + if (!(error instanceof AgentSessionJournalError && error.code === 'journal_closed')) { + console.warn('[agent-session-journal] saving the listing status failed', error) + } + }) + } + cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence @@ -252,7 +278,7 @@ export class AgentSessionJournal { state: this.state, rowsAfter: (afterSequence) => readJournalRowsAfterCursor( - this.requireDatabase().db, + this.database.db, this.identity.sessionId, this.state.epoch, afterSequence, @@ -354,16 +380,6 @@ export class AgentSessionJournal { 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 - } - /** * Assign the next sequence, make the row durable, and fold it through the * SAME reducer replay uses — all inside one serialized step, so concurrent 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..619517a13024 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,15 @@ 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 { insertJournalRow, publishJournalSessionEpoch } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import * as reducer from './journal-reducer' +import { openTestJournalHostDatabase } from './journal-host-database-test-support' let root: string -let opened: OpenJournalDatabase +let opened: JournalHostDatabase const sessionId = 'streaming-session' const epoch = 'epoch-1' @@ -48,13 +48,13 @@ function put(row: JournalRow): void { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-stream-replay-')) - opened = openJournalDatabase(journalDatabaseFile(root)) - upsertJournalSessionRow(opened.db, sessionId, epoch, 1) + opened = openTestJournalHostDatabase(root) + publishJournalSessionEpoch(opened.db, { sessionId: sessionId, workspaceId: 'ws-1' }, epoch) }) afterEach(async () => { vi.restoreAllMocks() - opened.db.close() + opened.close() await rm(root, { recursive: true, force: true }) }) @@ -86,7 +86,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 +108,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 +118,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) @@ -134,7 +134,7 @@ describe('streaming journal replay', () => { opened.db .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') .run(sessionId, epoch, 5, 5, '{') - const loaded = replayJournal(opened.db, false, sessionId)! + const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ readOnly: false, corrupt: true, @@ -147,7 +147,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 b0dff559b1ad..771cede858b5 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 @@ -18,7 +18,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 = { @@ -44,7 +44,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-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts new file mode 100644 index 000000000000..518e5c3737a1 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -0,0 +1,42 @@ +// 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 + + 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) + } + + /** Resolves once every write admitted so far has settled, whatever its outcome. */ + drain(): Promise { + return this.writes.then(() => undefined) + } + + private serializePastGate(run: () => Promise): Promise { + const started = this.writes.then(run) + this.writes = started.catch(() => undefined) + return started + } +} 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 17bdf7104905..e7ea73000305 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 @@ -7,7 +7,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' @@ -50,7 +50,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..998f32b1b1a4 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 + publishJournalSessionEpoch } 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 +} 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) + db.exec('BEGIN IMMEDIATE') + publishJournalSessionEpoch(db, { sessionId: identity.sessionId, workspaceId: 'ws-1' }, base.epoch) + insertJournalRow(db, identity.sessionId, { + ...base, + kind: 'epoch', + seq: 1, + reason: 'session_created', + providerHandle: identity.providerHandle + }) + for (let index = 0; index < count; index += 1) { insertJournalRow(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,11 @@ 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!) + db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( + identity.sessionId, + 4 + ) const first = readAgentSessionHistory(journal, { sessionId: identity.sessionId, direction: 'after', @@ -199,16 +192,12 @@ 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!) + db.prepare('UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND seq = ?').run( + rowJson, + identity.sessionId, + 4 + ) 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 cda878f5a01f..5c8c42c10a37 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 @@ -19,10 +19,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 +} from '../agent-session-journal/journal-host-database-test-support' import type { JournalItemRow, JournalRow, @@ -71,7 +72,7 @@ beforeEach(async () => { epochs = 0 journal = await journals.open({ identity: IDENTITY, - journalDir: root, + stateDirectory: root, now: tick, mintEpoch: () => { epochs += 1 @@ -483,7 +484,7 @@ 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 1c60029c7d32..96a6abc960bf 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 @@ -11,20 +11,20 @@ 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 { replayJournal } from '../agent-session-journal/journal-open' import { readJournalEpochRows } from '../agent-session-journal/journal-row-table' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + loadTestJournal +} 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' -// 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) } @@ -75,7 +75,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), @@ -91,7 +91,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', @@ -112,18 +112,13 @@ 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) } /** 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) + db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run(CODEX_SESSION, seq) }) } @@ -158,7 +153,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = journals.track( await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }).then((result) => result.journal) @@ -166,19 +161,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 () => { @@ -187,7 +182,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) @@ -202,7 +197,9 @@ 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( @@ -216,53 +213,67 @@ describe('openAgentSessionJournalWithRecovery', () => { ) }) - 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) await withJournalDatabase(journalDir, (db) => { const rows = readJournalEpochRows(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) => - db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - ) + // 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 } + ) + } + const neighbourEpoch = other.epoch + await other.close() + const database = openTestJournalHostDatabase(journalDir) + const neighbourBefore = readJournalEpochRows(database.db, neighbour.sessionId, neighbourEpoch) + await seedJournal(3) + await withJournalDatabase(journalDir, (db) => { + db.prepare('UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND seq = ?').run( + '}{', + CODEX_SESSION, + 3 + ) + }) const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database, fence: 1, historyFilePath }) journals.track(opened.journal) - expect(opened.recovery).toMatchObject({ - trigger: 'schema_unreadable', - reset: 'schema_unreadable' - }) + + expect(opened.recovery).toMatchObject({ trigger: 'journal_corrupt', reset: 'epoch_changed' }) expect(opened.recovery?.imported).toBeGreaterThan(0) - // No schema change, no row written, no row deleted. - 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 - }) - }) + const reopened = await journals.open({ identity: neighbour, stateDirectory: journalDir }) + expect(reopened.epoch).toBe(neighbourEpoch) + expect(readJournalEpochRows(database.db, neighbour.sessionId, neighbourEpoch)).toEqual( + neighbourBefore + ) }) // The rehydrate deletes every live row to publish its replacement epoch, so @@ -270,7 +281,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', @@ -300,7 +311,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath }) @@ -318,7 +329,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const opened = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: join(root, 'missing.jsonl') }) @@ -341,7 +352,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: empty }) @@ -353,19 +364,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 @@ -379,7 +390,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: missing }) @@ -389,10 +400,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 }) @@ -426,7 +437,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: empty }) @@ -448,12 +459,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 }) @@ -469,7 +480,7 @@ describe('openAgentSessionJournalWithRecovery', () => { const first = await openAgentSessionJournalWithRecovery({ identity: IDENTITY, - journalDir, + database: openTestJournalHostDatabase(journalDir), fence: 1, historyFilePath: join(root, 'missing.jsonl') }) @@ -478,7 +489,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..1568a6984dea 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,22 @@ 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 }): 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 }) - 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 +68,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 fb4ee607e38d..5cd6bfa721b8 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 @@ -19,7 +19,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 { @@ -43,6 +42,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' } @@ -85,7 +85,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 }, @@ -237,10 +237,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..f3984bbaf968 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,8 @@ describe('structured session acquisition options', () => { const first = await performAttach({ store: initialStore, adapter: withHistory('created'), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -195,8 +196,8 @@ describe('structured session acquisition options', () => { const second = await performAttach({ store, adapter: withHistory('resumed'), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -230,8 +231,8 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: sessionAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -263,8 +264,8 @@ describe('structured session acquisition options', () => { performAttach({ store, adapter: sessionAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken, claimKeyId: 'key-1', @@ -295,8 +296,8 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: adapter({ origin: 'created' }), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -335,8 +336,8 @@ describe('structured session acquisition options', () => { models: [] } }), - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-b', claimKeyId: 'key-1', @@ -375,8 +376,8 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: sessionAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -415,8 +416,8 @@ describe('structured session acquisition options', () => { performAttach({ store, adapter: failingAdapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -510,8 +511,11 @@ describe('structured session acquisition options', () => { performAttach({ store: target, adapter: failingAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!, failingAdapter), + journalDatabase: openTestJournalHostDatabase(root!), + openConversation: openTestAttachConversation( + openTestJournalHostDatabase(root!), + failingAdapter + ), authority: { spawnToken: operationId === CREATE_OPERATION ? 'spawn-a' : 'spawn-b', claimKeyId: 'key-1', @@ -612,8 +616,8 @@ describe('the tab a create reserves', () => { return performAttach({ store, adapter: adapter({ origin: 'created' }), - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.test.ts index 4b8e1d87ba39..b3c2c635777d 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 @@ -14,9 +14,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' @@ -122,8 +122,8 @@ async function attach( return performAttach({ store, adapter: sessionAdapter, - journalRoot: root!, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root!), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -220,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 () => { @@ -237,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 55128a23c9c5..9ea7affb1ea4 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 @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -135,7 +136,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 40804bf55e2e..1de96dac7b88 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 @@ -136,7 +136,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..3a863e95ce96 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,7 +181,7 @@ describe('attachJournal restart reconciliation', () => { const attached = await attachJournal({ record: RECORD, params: PARAMS, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(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-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-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 f5410de74f62..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-close-retry.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -// 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' - } - ) - ).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' - } - ) - ).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-open.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-conversation-open.ts index bce79ca71f87..a8eb35c90fbf 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 { openAgentSessionJournalWithRecovery } from './agent-session-journal-recovery' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import type { AgentSessionRecord } from '../../../shared/agent-session-record' @@ -36,7 +36,7 @@ export type OpenedStructuredAgentSessionConversation = { export type StructuredAgentSessionConversationOpenDeps = { store: Pick adapter: Pick - journalRoot: string + journalDatabase: JournalHostDatabase onEventSinkError?: StructuredAgentSessionHostDeps['onEventSinkError'] } @@ -89,10 +89,7 @@ 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 }) 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 b294540990d9..c6ed9cb0d644 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 @@ -4,7 +4,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' @@ -30,7 +30,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-dead-generation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-dead-generation-settlement.test.ts index 4a0febb6ceb9..e2af9e6934ef 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 @@ -15,6 +15,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.' @@ -34,7 +35,7 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + database: openTestJournalHostDatabase(root), now: () => 1_000 }) }) @@ -402,7 +403,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 90e07ddf88ca..92c14f4fca5e 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 @@ -15,7 +15,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' @@ -106,7 +109,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' }] }, @@ -170,7 +173,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 8181110a5a6b..f008eb7191ea 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 @@ -21,6 +21,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' } @@ -128,7 +129,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-teardown.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host-teardown.ts index 97a27d5c246c..4c7bbca5b0ee 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 type { StructuredAgentSessionRestartResume } from './structured-agent-session-restart-resume-host' @@ -14,7 +13,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 = { @@ -109,7 +107,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) @@ -119,8 +117,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) @@ -130,10 +128,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 1e0d77b0d524..110a5cf02ac9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts @@ -8,7 +8,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 @@ -143,7 +146,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), recoveryCapsule: new AgentSessionRecoveryCapsule(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', @@ -152,8 +155,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..dadf5d30da11 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, opened under the owner lock. */ + 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 58a84cecd5f7..65bb215bbd92 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 @@ -26,6 +26,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 @@ -132,7 +133,7 @@ describe('attach', () => { host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), acquire }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -561,7 +562,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-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 8dabd942705e..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-journal-handles.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -// 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 } - ) - } - await seeded.close() - // Punch a hole in the middle so recovery takes the `journal_corrupt` branch. - const { openJournalDatabase } = await import('../agent-session-journal/journal-database') - const opened = openJournalDatabase(journalDatabaseFile(journalDir)) - opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(3) - opened.db.close() - legacyImport.throws = true - - await expect( - openAgentSessionJournalWithRecovery({ - identity: IDENTITY, - journalDir, - fence: 1, - historyFilePath: join(root, 'missing.jsonl') - }) - ).rejects.toThrow('legacy import threw') - await expectNothingHoldsTheDirectory(journalDir) - }) -}) - -describe('sites 9 and 10: closing a conversation handle', () => { - it('drops the map entry before the close, and releases the handle', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir }) - const sessions = new Map([[SESSION, hostSession(journal)]]) - const order: string[] = [] - const close = journal.close.bind(journal) - journal.close = async () => { - // A lock-free reader arriving now must find no entry, never a closing handle. - order.push(sessions.has(SESSION) ? 'close-while-indexed' : 'close-after-delete') - await close() - order.push('closed') - } - - await expect( - closeStructuredAgentSessionConversationUnderSerialize( - { sessions, closeStatus: () => order.push('status') }, - SESSION - ) - ).resolves.toBe(true) - - expect(order).toEqual(['status', 'close-after-delete', 'closed']) - expect(sessions.size).toBe(0) - await expectNothingHoldsTheDirectory(journalDir) - }) - - it('surfaces a rejected close to its caller', async () => { - const journal = await journals.open({ identity: IDENTITY, journalDir }) - const sessions = new Map([[SESSION, hostSession(journal)]]) - const close = journal.close.bind(journal) - journal.close = () => Promise.reject(new Error('close rejected')) - - await expect( - closeStructuredAgentSessionConversationUnderSerialize( - { sessions, closeStatus: () => undefined }, - SESSION - ) - ).rejects.toThrow('close rejected') - journal.close = close - }) -}) - -describe('site 11: host teardown is failure-complete', () => { - async function twoSessions(): Promise> { - const first = await journals.open({ identity: IDENTITY, journalDir }) - const second = await journals.open({ - identity: { ...IDENTITY, sessionId: `${SESSION}-b` }, - journalDir: join(root, 'journal-b') - }) - return new Map([ - [SESSION, hostSession(first)], - [`${SESSION}-b`, hostSession(second)] - ]) - } - - it('closes every journal and clears the map on the happy path', async () => { - const sessions = await twoSessions() - const acknowledgeSessionRelease = vi.fn() - await tearDownStructuredAgentSessionHost({ - phases: [], - sessions, - acknowledgeSessionRelease - }) - - expect(sessions.size).toBe(0) - expect(acknowledgeSessionRelease.mock.calls).toEqual([[SESSION], [`${SESSION}-b`]]) - await expectNothingHoldsTheDirectory(journalDir) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) - - // Against a trailing-statement design this case fails: `flushAllEventSinks` - // throws by design, so the close would be skipped on exactly the leaking path. - it('still closes every journal when a teardown phase throws', async () => { - const sessions = await twoSessions() - const barrierError = new Error('sink barrier failed') - - await expect( - tearDownStructuredAgentSessionHost({ - phases: [ - { - name: 'flush-event-sinks', - run: () => { - throw barrierError - } - } - ], - sessions - }) - ).rejects.toMatchObject({ errors: [barrierError] }) - - expect(sessions.size).toBe(0) - await expectNothingHoldsTheDirectory(journalDir) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) - - it('keeps the entry whose close rejected, and surfaces the rejection', async () => { - const sessions = await twoSessions() - const acknowledgeSessionRelease = vi.fn() - const failing = sessions.get(SESSION) - const closeError = new Error('close rejected') - if (failing) { - sessions.set(SESSION, { - ...failing, - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `close`, and this map is a plain `Map` that binds no delivery. - journal: { - close: () => Promise.reject(closeError) - } as unknown as AgentSessionJournal - }) - } - - await expect( - tearDownStructuredAgentSessionHost({ phases: [], sessions, acknowledgeSessionRelease }) - ).rejects.toMatchObject({ errors: [closeError] }) - - // Only the failure stays indexed — `status === 'fulfilled'`, not "settled". - expect([...sessions.keys()]).toEqual([SESSION]) - expect(acknowledgeSessionRelease).toHaveBeenCalledExactlyOnceWith(`${SESSION}-b`) - await expectNothingHoldsTheDirectory(join(root, 'journal-b')) - }) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts index 2194b992fb0e..049c55798dc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-late-settlement.test.ts @@ -26,6 +26,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' } @@ -116,7 +117,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts index 213126fb5eb5..cea81c6c4fe4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-legacy-handoff-record.test.ts @@ -22,6 +22,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -50,7 +51,7 @@ function openHost(): void { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 094ddffdcb27..71750505fef1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -29,7 +29,7 @@ async function context(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: join(hostTestState().root, 'settlement') + stateDirectory: join(hostTestState().root, 'settlement') }), fence: 1, adapter: adapter(), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts index 3eae8a7ae08f..7445c1e3894c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-option-settlement.test.ts @@ -19,6 +19,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const DEFAULT_MODEL = 'gpt-default' @@ -124,7 +125,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: router, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-native', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts index 9da47ae1f22d..340b35cf21ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts @@ -29,6 +29,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const SWEEP_MS = 5 @@ -88,7 +89,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts index 89772d454690..fd41ba7890f2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts @@ -16,6 +16,7 @@ import { } from './structured-agent-session-attach' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -84,8 +85,8 @@ async function firstAnswerAndReplay(thrown: AgentSessionPreSpawnError) { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index 348d5a3d90cb..fa3a9b74e552 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -14,6 +14,7 @@ import { } from './structured-agent-session-attach' import { openTestAttachConversation } from './structured-agent-session-attach-test-conversation' import { performAttach } from './structured-agent-session-attach-flow' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const NOW = 1_800_000_000_000 const SESSION = 'session-alpha' @@ -86,8 +87,8 @@ describe('processless structured session reservation', () => { performAttach({ store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -134,8 +135,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -174,8 +175,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-drift', claimKeyId: 'key-1', @@ -227,8 +228,8 @@ describe('processless structured session reservation', () => { performAttach({ store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', @@ -294,8 +295,8 @@ describe('processless structured session reservation', () => { const input = { store, adapter, - journalRoot: root, - openConversation: openTestAttachConversation(root!), + journalDatabase: openTestJournalHostDatabase(root), + openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', claimKeyId: 'key-1', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts index a9e32b026e6a..642dfee26314 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -35,7 +35,7 @@ afterEach(async () => { async function pendingPrompt(): Promise<{ journal: AgentSessionJournal; itemId: string }> { root = await mkdtemp(join(tmpdir(), 'orca-prompt-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const item = await journal.appendItem( PROMPT_IDENTITY, { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts index fb66a1e1a8fd..8e674598ffdb 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts @@ -21,7 +21,6 @@ import { import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { ensureStructuredAgentSessionAgent } from './structured-agent-session-agent-start' @@ -37,6 +36,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' } @@ -88,7 +88,7 @@ function startHost(): void { setOption: vi.fn(async () => undefined), ...adapterExtras }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW @@ -331,10 +331,7 @@ describe('settling an earlier child before the next one takes its message', () = agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: journalDirectoryFor(root, { - workspaceId: HOST_TEST_LOCATION.workspaceId, - sessionId: SESSION - }) + database: openTestJournalHostDatabase(root) }) await journal.appendItem( { provider: 'codex', threadId: THREAD, turnId: 'earlier-turn', ordinal: 0 }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts index 5da0861aa197..e5025551360b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts @@ -14,6 +14,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CLAUDE_SESSION = 'claude-session' const hosts: StructuredAgentSessionHost[] = [] @@ -56,7 +57,7 @@ function createHost( const host = new StructuredAgentSessionHost({ store, adapter: claudeAdapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts index 7c195a9a1243..4ac380c0c6ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts @@ -21,6 +21,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const INIT_DELAY_MS = 40 @@ -66,7 +67,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-read-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts index 514008e49f9b..83238e95d74f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts @@ -13,6 +13,10 @@ import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { restoreStructuredAgentSessionRead } from './structured-agent-session-read-restore' const SESSION_ID = 'codex_read_restore_fixture' @@ -50,7 +54,7 @@ const store = { let journalRoot: string const openDeps = () => ({ store, - journalRoot, + journalDatabase: openTestJournalHostDatabase(journalRoot), adapter: {} }) const opened: AgentSessionJournal[] = [] @@ -71,6 +75,7 @@ beforeEach(async () => { afterEach(async () => { await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) + closeTestJournalHostDatabases() await rm(journalRoot, { recursive: true, force: true }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 0e1441b729c7..16adc64d4531 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -1,6 +1,7 @@ -import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' import { existsSync } from 'node:fs' -import { journalDatabaseFile, journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { findJournalFileFormatRemnant } from '../agent-session-journal/journal-file-format-remnant' +import { legacyJournalDatabaseFile } from '../agent-session-journal/journal-paths' +import { readJournalSessionEpoch } from '../agent-session-journal/journal-row-table' import { openStructuredAgentSessionConversationJournal, type OpenedStructuredAgentSessionConversation, @@ -20,13 +21,20 @@ export async function restoreStructuredAgentSessionRead( if (!record) { return null } - const journalDir = journalDirectoryFor(deps.journalRoot, { - workspaceId: record.location.workspaceId, - sessionId - }) - // A session still in the pre-SQLite format has no `journal.db`; the open imports it. - if (!existsSync(journalDatabaseFile(journalDir)) && !findJournalFileFormatRemnant(journalDir)) { - return null + const database = deps.journalDatabase + if (readJournalSessionEpoch(database.db, sessionId) === null) { + // Not in the host's database yet: its history may still sit in a per-chat file the open + // imports, or in the pre-SQLite format the open explains. + const legacyDirectory = database.legacyDirectoryFor({ + workspaceId: record.location.workspaceId, + sessionId + }) + if ( + !existsSync(legacyJournalDatabaseFile(legacyDirectory)) && + !findJournalFileFormatRemnant(legacyDirectory) + ) { + return null + } } return openStructuredAgentSessionConversationJournal(deps, record) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts index a2be16103274..10382a7d6397 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovered-turn-clock.test.ts @@ -12,7 +12,7 @@ import type { AgentSessionTurnCompletionEvent } from '../../../shared/agent-session-wire' import { AgentHookServer, _internals } from '../../agent-hooks/server' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { settleStaleStructuredAgentSessionState, @@ -54,7 +54,7 @@ async function sessionWithRunningTurn() { providerHandle: { kind: 'codex', threadId: THREAD } }, now: () => clock, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { provider: 'orca', clientMessageId: 'prompt-1' }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts index 2da22f958733..4728d4256de2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-recovery-exits.test.ts @@ -20,6 +20,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -81,7 +82,7 @@ function openHost(overrides: Partial = {}): void host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 4043e27b30c7..4c2db496c3cf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -27,6 +27,7 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const METHODS = ['agentSession.setOption', 'agentSession.send'] as const @@ -91,7 +92,7 @@ async function createHarness(options: { attached?: boolean } = {}) { const host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts index 3ea116fa361d..782c1f161156 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rest-test-rig.ts @@ -30,6 +30,7 @@ import { resetHostTestOperationIds } from './structured-agent-session-host-test-data' import { STRUCTURED_AGENT_SESSION_IDLE_MS } from './structured-agent-session-idle-sweep' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' export const REST_TEST_CALLER = { callerKey: 'client-1' } export const IDLE_MS = STRUCTURED_AGENT_SESSION_IDLE_MS @@ -159,7 +160,7 @@ export async function createRestTestRig( answerPrompt: async ({ commit }) => commit(), setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner: async () => ({ outcome: 'pid-absent' }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index d88a6330e789..e6b0b9dba5c2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -28,6 +28,7 @@ import { hostTestAttachParams, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Starts the agent explicitly — the attach a client's ensure makes — for a test that needs a * running child before its next step. Nothing else starts one ahead of a send. */ @@ -132,7 +133,7 @@ export async function interruptedRestart( } : {}) }, - journalRoot: previous.root, + journalDatabase: openTestJournalHostDatabase(previous.root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', probeOwner, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts index aefa34f4b673..a9aeb826a7f9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-offer-endings.test.ts @@ -3,9 +3,8 @@ import { expect, it, vi } from 'vitest' import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' -import { rename, writeFile, rm } from 'node:fs/promises' import { interruptedRestart } from './structured-agent-session-restart-interruption-test-harness' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' +import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { HOST_TEST_NOW as NOW, @@ -36,24 +35,16 @@ it('deletes the offer once the user sends their own message in that chat', async // A journal this host cannot read decides nothing, so it must not end the offer. it('keeps the offer when the chat cannot be read on this host', async () => { - const { host, root, store } = await interruptedRestart('submission') + const { host, root } = await interruptedRestart('submission') const capsule = new AgentSessionRecoveryCapsule(root) - const location = store.getRecord(SESSION)?.location - if (!location) { - throw new Error('missing session record') - } - // A file where the journal directory belongs: this host cannot open the conversation at all. - const journalDir = journalDirectoryFor(root, { - workspaceId: location.workspaceId, - sessionId: SESSION - }) - await rename(journalDir, `${journalDir}.aside`) - await writeFile(journalDir, 'not a journal') + // This host cannot open the conversation at all. + const open = vi + .spyOn(AgentSessionJournal.prototype, 'open') + .mockRejectedValue(Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR' })) try { expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) } finally { - await rm(journalDir) - await rename(`${journalDir}.aside`, journalDir) + open.mockRestore() } expect(await capsule.list(NOW)).toHaveLength(1) expect(await host.restartResume.list()).toMatchObject([{ sessionId: SESSION }]) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts index 524d8943fac2..87e87a408c32 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-status-publication.test.ts @@ -25,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -57,7 +58,7 @@ function createHost(store: AgentSessionRecordStore): StructuredAgentSessionHost const host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', probeOwner: async () => ({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts index a596cc5c9edb..6d1a727af374 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind-at-rest.test.ts @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const caller = { callerKey: 'desktop' } const KEPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'kept', ordinal: 0 } @@ -77,7 +78,7 @@ function openHost(): StructuredAgentSessionHost { return new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts index a8ded60e6ffb..b63b6d201878 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-rewind.test.ts @@ -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: 'desktop' } let directory: string @@ -93,7 +94,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts index 2494535e019b..1925c13378ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-idempotency.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performSend, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -25,7 +25,7 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: root + stateDirectory: root }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts index a1f196d8b612..069a63ed58d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-open-stale-turn.test.ts @@ -24,6 +24,7 @@ import { HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -73,7 +74,7 @@ async function relaunchAfterCrashMidTurn( const host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), acquire }, - journalRoot: relaunched, + journalDatabase: openTestJournalHostDatabase(relaunched), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-next', probeOwner: async () => probe, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index 259ad16c858e..e435110f1399 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -79,7 +80,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, now: () => NOW, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts index a7a0e9710556..3a35e2059ca9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.ts @@ -12,11 +12,7 @@ import { } from '../../../shared/agent-session-wire' import { TUI_AGENT_DISPLAY_NAMES } from '../../../shared/tui-agent-display-names' import type { AgentSessionFailureWordsContext } from '../../../shared/agent-session-failure-words' -import { agentSessionWriteNoticeEnglish } from '../../../shared/agent-session-refusal-notice' -import { - classifyJournalOpenFailure, - type JournalOpenFailure -} from '../agent-session-journal/journal-open-failure' +import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionHostSession } from './structured-agent-session-host-types' import { AGENT_SESSION_NOT_ATTACHED, @@ -77,20 +73,10 @@ export async function openConversationForWrite( return { ok: false, refusal: AGENT_SESSION_NOT_ATTACHED } } catch (error) { console.warn('[agent-session] opening the conversation for a write failed:', error) - const reason = classifyJournalOpenFailure(error) - return { - ok: false, - refusal: refuse('agent_session_journal_unreadable', { reason }, JOURNAL_OPEN_MESSAGE[reason]) - } + return { ok: false, refusal: journalOpenRefusal(error) } } } -// Released clients print a refusal's message for a send; it fits a Stop too. -const JOURNAL_OPEN_MESSAGE: Record = { - journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), - journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']) -} - /** The conversation a write lands in, opened when this host holds it closed. */ export function openForWrite( context: Pick, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts index f339aa1113fc..d8390feae1c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-restarts-failed-start.test.ts @@ -27,6 +27,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -154,7 +155,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts index 7602afa230be..27b408933b7c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-settled-attach-retry.test.ts @@ -24,6 +24,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } @@ -112,7 +113,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -139,7 +140,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: { ...adapter(), historyFilePath }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW @@ -196,7 +197,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, now: () => NOW @@ -240,7 +241,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, // A host that cannot read another process's environment, so no scan can prove anything. @@ -270,7 +271,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken, // A host that cannot read another process's environment, so no scan can prove anything. @@ -327,7 +328,7 @@ describe('settled attach retry', () => { host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-restarted', probeOwner: async () => ({ outcome: 'pid-absent' }), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts index 4ab7b3ad010c..60ddca411e66 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-stale-turn-verdict.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentJournalRenderItem } 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 { settleStaleStructuredAgentSessionState } from './structured-agent-session-dead-generation-settlement' import { runningTurnLifecycleRevisions, @@ -276,7 +276,7 @@ describe('stale session state on a cold acquire', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: THREAD } }, - journalDir: root, + stateDirectory: root, now: () => 1_000 }) const child = { agentId: 'thread-child', producerKind: 'agent' as const } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts index 5ce1fdb3694e..69bed14b7b12 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-start-failure-writer.test.ts @@ -25,6 +25,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const EXIT_REASON = 'Claude Code is not signed in. Sign in with the Claude CLI' @@ -140,7 +141,7 @@ beforeEach(async () => { answerPrompt: vi.fn(async () => undefined), setOption: vi.fn(async () => undefined) }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation + 1}`, now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts index 1731674baad3..03c490742ce3 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-child-work.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import { makeStructuredAgentStatusSubject } from '../../../shared/agent-status-subject' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed, type StructuredAgentSessionStatusSink @@ -34,7 +34,7 @@ async function feedWith(sink: StructuredAgentSessionStatusSink) { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const session = indexedStatusFeedSession({ journal }) return new StructuredAgentSessionStatusFeed({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts index dcd06c0c7ef3..8d93d855fdc9 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-clock.test.ts @@ -10,7 +10,7 @@ import type { AgentJournalItemIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wire' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -56,7 +56,7 @@ async function openFeed() { providerHandle: { kind: 'codex', threadId: THREAD } }, now: () => (clock += 100), - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts index a26cd324217f..af72d80324ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.test.ts @@ -12,7 +12,7 @@ import type { import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { publishCodexTurnLifecycle } from '../../codex/codex-structured-journal-translation-turns' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { indexedStatusFeedSession as indexed } from './structured-agent-session-status-feed-test-session' import { StructuredAgentSessionStatusFeed, @@ -58,7 +58,7 @@ async function openJournal(sessionId = SESSION, now?: () => number) { providerHandle: { kind: 'codex', threadId: 'thread-1' } }, now, - journalDir: join(root, sessionId) + stateDirectory: join(root, sessionId) }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 05937aca5148..a84cc57ece5e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -23,6 +23,7 @@ import { } from '../../../shared/agent-session-wire' import type { AgentChildWorkEvidence } from '../../../shared/agent-status-child-work-evidence' import { projectStructuredAgentSessionStatusState } from '../../../shared/structured-agent-session-projection' +import { isSavableStructuredAgentSessionProjection } from '../../../shared/structured-agent-session-saved-status' import { structuredAgentSessionAgentStatus } from '../../../shared/structured-agent-session-agent-status' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { StructuredAgentSessionProviderChildPhase } from './structured-agent-session-adapter' @@ -330,6 +331,9 @@ export class StructuredAgentSessionStatusFeed { ) } this.journalProjections.set(journal, projection) + if (!readOnly && isSavableStructuredAgentSessionProjection(projection.state.summary)) { + journal.saveListingStatus(projection.state.summary) + } } return projection.state } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts index 57218d0f4bdc..4cbe10aab076 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-reentry.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionExecutionLocation } from '../../../shared/agent-session-record' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { AgentHookServer } from '../../agent-hooks/server' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' import { @@ -34,7 +34,7 @@ async function openJournal(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts index 4053988233ff..ddf9566bf82e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subagent-recency.test.ts @@ -18,7 +18,7 @@ import { projectStructuredAgentSessionStatusSummary } from '../../../shared/stru import { AgentHookServer, _internals } from '../../agent-hooks/server' import { createClaudeJournalTranslator } from '../../claude/claude-structured-journal-translation' import { createCodexJournalTranslator } from '../../codex/codex-structured-journal-translation' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from './structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' @@ -57,7 +57,7 @@ async function openSession() { providerHandle: { kind: 'codex', threadId: CODEX_THREAD } }, now: tick, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) // The roster the provider adapter reports, and the host's status row the feed writes into. const roster: { tasks: AgentSessionBackgroundTask[] } = { tasks: [] } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index 82f8c551642b..fcced1db4174 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -11,11 +11,12 @@ import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -44,7 +45,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'activity-churn-journal') + stateDirectory: join(root, 'activity-churn-journal') }) const subscribers = new AgentSessionSubscribers() @@ -64,7 +65,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'checkpoint-journal') + stateDirectory: join(root, 'checkpoint-journal') }) const events: AgentSessionSubscribeEvent[] = [] @@ -103,7 +104,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'clock-journal') + stateDirectory: join(root, 'clock-journal') }) let now = 1_000 const events: AgentSessionSubscribeEvent[] = [] @@ -148,7 +149,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'catalog-journal') + stateDirectory: join(root, 'catalog-journal') }) let commands = [{ name: 'first', kind: 'skill' as const }] const events: AgentSessionSubscribeEvent[] = [] @@ -191,7 +192,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'hook-journal') + stateDirectory: join(root, 'hook-journal') }) const published: string[] = [] const subscribers = new AgentSessionSubscribers({ @@ -220,7 +221,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'unread-journal') + stateDirectory: join(root, 'unread-journal') }) const statusFeed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ @@ -285,7 +286,7 @@ describe('AgentSessionSubscribers', () => { agent: 'claude', providerHandle: { kind: 'claude', sessionId: 'provider-1', leafUuid: null } }, - journalDir: join(root, 'background-journal') + stateDirectory: join(root, 'background-journal') }) const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] @@ -334,7 +335,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'activity-journal') + stateDirectory: join(root, 'activity-journal') }) const subscribers = new AgentSessionSubscribers() const events: AgentSessionSubscribeEvent[] = [] @@ -386,7 +387,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir + stateDirectory: journalDir }) // A row admitted before identity bounding: its removal id alone exceeds // the outbound cap, so no catch-up batch can ever carry it. @@ -419,7 +420,7 @@ describe('AgentSessionSubscribers', () => { // Staged straight into the session database, exactly as a previous writer // would have committed them. await seeded.close() - const opened = openJournalDatabase(journalDatabaseFile(journalDir)) + const opened = openTestJournalHostDatabase(journalDir) try { opened.db.exec('BEGIN IMMEDIATE') for (const row of rows) { @@ -427,7 +428,7 @@ describe('AgentSessionSubscribers', () => { } opened.db.exec('COMMIT') } finally { - opened.db.close() + opened.close() } const journal = await journals.open({ identity: { @@ -437,7 +438,7 @@ describe('AgentSessionSubscribers', () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir + stateDirectory: journalDir }) const subscribers = new AgentSessionSubscribers() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts index 98e50783bdf3..2760667473c7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-surface-lifetime.test.ts @@ -38,6 +38,7 @@ import { } from './structured-agent-session-host-test-data' import { agentSessionFailureFact } from '../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const UNEXPECTED_PROVIDER_EXIT_OUTCOME = 'Codex stopped while this response was in progress. You can continue in this conversation.' @@ -75,7 +76,7 @@ function openHost( host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${acquire.mock.calls.length}`, idleSweep: { intervalMs: SWEEP_MS, idleMs: IDLE_MS }, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts index f490eb2e9cdf..fb7f638845c4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-thread-goal.test.ts @@ -7,7 +7,7 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { journalRecordsThreadGoalChange, @@ -37,7 +37,7 @@ afterEach(async () => { async function openJournal(): Promise { root ??= await mkdtemp(join(tmpdir(), 'orca-thread-goal-')) - return journals.open({ identity: IDENTITY, journalDir: root }) + return journals.open({ identity: IDENTITY, stateDirectory: root }) } const GOAL: AgentJournalThreadGoal = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts index 576743ebac49..d05e6488bcfe 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { performCancel, type AgentSessionTurnContext } from './structured-agent-session-turns' @@ -29,7 +29,7 @@ afterEach(async () => { describe('performCancel', () => { it('acknowledges only the request and leaves the running lifecycle row intact', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const lifecycleIdentity = { provider: 'legacy' as const, agent: 'codex' as const, @@ -77,7 +77,7 @@ describe('performCancel', () => { it('hands the adapter a live-turn read of the published journal', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-live-turn-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const lifecycleIdentity = { provider: 'legacy' as const, agent: 'codex' as const, @@ -130,7 +130,7 @@ describe('performCancel', () => { it('keeps the running lifecycle when cancellation cannot be confirmed', async () => { root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) await journal.appendItem( { provider: 'legacy', @@ -177,7 +177,7 @@ describe('performCancel', () => { it('stops background tasks without interrupting the foreground turn or writing a row', async () => { root = await mkdtemp(join(tmpdir(), 'orca-background-task-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { @@ -209,7 +209,7 @@ describe('performCancel', () => { it('routes one background task id without interrupting the foreground turn or writing a row', async () => { root = await mkdtemp(join(tmpdir(), 'orca-background-task-targeted-cancel-')) - const journal = await journals.open({ identity: IDENTITY, journalDir: root }) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) const cancelTurn = vi.fn(async () => ({ cancelled: true })) const stopBackgroundTasks = vi.fn(async () => ({ cancelled: true })) const ctx: AgentSessionTurnContext = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 49642b9a897e..49e57bd52232 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -34,6 +34,10 @@ import type { import { structuredAgentSessionStartFailure } from './structured-agent-session-failure-text' import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { + classifyJournalOpenFailure, + journalOpenRefusal +} from '../agent-session-journal/journal-open-failure' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -139,7 +143,11 @@ export async function performSend( } try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) - } catch { + } catch (error) { + // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. + if (classifyJournalOpenFailure(error) === 'journalCorrupt') { + return { ok: false, refusal: journalOpenRefusal(error) } + } return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') } return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts index 957ac2f275db..a49d411c9cf8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-view-start-after-failed-start.test.ts @@ -23,6 +23,7 @@ import { hostTestOperationId, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' /** Delivery runs on its own serialized steps; under a loaded runner they take more than a second. */ function eventually(assertion: () => unknown): Promise { @@ -84,7 +85,7 @@ beforeEach(async () => { host = new StructuredAgentSessionHost({ store, adapter: Object.assign(adapter, { supportsCreate: () => true }), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${claude.connections.length + 1}`, now: () => NOW diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts index 98b212a8606c..912346facd51 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wedged-profile-migration.test.ts @@ -34,7 +34,6 @@ import { AgentSessionRecordStore } from '../../runtime/agent-session-record-stor import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' -import { journalDirectoryFor } from '../agent-session-journal/journal-paths' import { AgentSessionJournal } from '../agent-session-journal/journal-store' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' @@ -46,6 +45,7 @@ import { hostTestAttachParams, resetHostTestOperationIds } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' const CALLER = { callerKey: 'client-1' } const DEAD_OWNER: AgentSessionProcessIdentity = { @@ -140,7 +140,7 @@ function openHost(overrides: Partial = {}): void setOption: vi.fn(), supportsCreate: () => true } as unknown as StructuredAgentSessionAdapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-new', now: () => NOW, @@ -203,7 +203,7 @@ async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise< ? { kind: 'codex', threadId: THREAD } : { kind: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null } }, - journalDir: journalDirectoryFor(root, { workspaceId: LOCATION.workspaceId, sessionId: SESSION }) + database: openTestJournalHostDatabase(root) }) await journal.appendItem( provider === 'codex' diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 585cbd4fbbdf..530b665d81f5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -10,12 +10,13 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-w import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { mobileE2EETextPayloadAdmissionBytes } from '../../runtime/rpc/mobile-e2ee-outbound-admission' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { openJournalDatabase } from '../agent-session-journal/journal-database' -import { journalDatabaseFile } from '../agent-session-journal/journal-paths' import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../agent-session-journal/journal-store-test-open' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from './agent-session-history-page' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -36,7 +37,7 @@ beforeEach(async () => { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: root + stateDirectory: root }) for (let ordinal = 1; ordinal <= 20; ordinal += 1) { await journal.appendItem(item(ordinal), body(`${ordinal}:${LARGE_TEXT}`), { fence: 1 }) @@ -167,7 +168,7 @@ async function reopenWithOversizedRemoval(afterSequence: number): Promise { host = new StructuredAgentSessionHost({ store, adapter, - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, mintSpawnToken: () => `spawn-${acquisitions}` diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 02533588e9dd..92ae74b567a8 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -119,6 +119,8 @@ async function startOrcadRuntime( ): Promise> { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') + const { setJournalOwnerProcessKind } = + await import('../runtime/structured-agent-session-journal-ownership') const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = await import('../ipc/pty') const { getAppEnvironment } = await import('../../shared/app-environment') @@ -290,9 +292,11 @@ async function startOrcadRuntime( observedStatusCapture.attach(runtime) const bindHost = resolveOrcadBindHost(options.bind) + setJournalOwnerProcessKind('orcad') rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, + journalStateDirectory: runtimeUserDataPath, enableWebSocket: true, // Why pinned and not `exposeNetworkByDefault`: an unattended host's exposure must be // exactly what the operator asked for, on every launch. The default path widens itself diff --git a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts index a2b049e4190e..4748c7dd5a67 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-adoption-replay.test.ts @@ -12,6 +12,7 @@ import { OrcaRuntimeService } from '../../orca-runtime' import type { RpcRequest, RpcResponse } from '../core' import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const SESSION = 'session-adoption-replay' const THREAD = 'thread-adoption-replay' @@ -183,7 +184,7 @@ describe('committed adopting create RPC replay', () => { host = new StructuredAgentSessionHost({ store, adapter: sessionAdapter, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1' }) setStructuredAgentSessionHost(host) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts index a3d598a69d88..b86b632e359c 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-at-rest.test.ts @@ -5,13 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope' import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection' -import { openJournalDatabase } from '../../../native-chat/agent-session-journal/journal-database' import { - journalDatabaseFile, - journalDirectoryFor -} from '../../../native-chat/agent-session-journal/journal-paths' -import { - HOST_TEST_LOCATION, hostTestAttachParams, hostTestMessage, hostTestOperationId @@ -33,6 +27,7 @@ import { RpcDispatcher } from '../dispatcher' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' import { discardStructuredWorkerSession } from './orchestration-structured-worker-session' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const CLIENT = { clientId: 'device-1', @@ -239,17 +234,10 @@ describe('the accessor', () => { it('opens a corrupt journal through the recovering open and still accepts a send (P2-03)', async () => { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() - const directory = journalDirectoryFor(rig.root, { - workspaceId: HOST_TEST_LOCATION.workspaceId, - sessionId: SESSION - }) // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds. - const opened = openJournalDatabase(journalDatabaseFile(directory)) - try { - opened.db.prepare('UPDATE journal_rows SET row_json = ? WHERE seq = ?').run('}{', 2) - } finally { - opened.db.close() - } + openTestJournalHostDatabase(rig.root) + .db.prepare('UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND seq = ?') + .run('}{', SESSION, 2) await rig.restart() setStructuredAgentSessionHost(rig.host) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index e4ff861d1ac7..f8ae5ab38fbe 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -13,6 +13,7 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' +import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-journal-ownership' import { supportsStructuredAgentSessionCapability, supportsStructuredAgentSessions @@ -36,13 +37,7 @@ export function requireStructuredCapability(ctx: RpcContext): void { export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHost { requireStructuredCapability(ctx) - const host = getStructuredAgentSessionHost() - if (!host) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostDisabled' - }) - } - return host + return requireHostOrRefusal() } /** @@ -73,13 +68,23 @@ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSe reason: 'clientCapabilityMissing' }) } + return requireHostOrRefusal() +} + +/** + * The host, or why there is none. A process whose chats another Orca owns, or whose journal would + * not open, says so under every getter — cleanup included: nothing here can stop a child it never + * started, and the words tell the user where their chats are. + */ +function requireHostOrRefusal(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() - if (!host) { - throw agentSessionRefusalError('structured_agent_session_unsupported', { - reason: 'hostDisabled' - }) + if (host) { + return host } - return host + throw ( + structuredAgentSessionHostRefusal() ?? + agentSessionRefusalError('structured_agent_session_unsupported', { reason: 'hostDisabled' }) + ) } /** Builds the host for a call that may be the first this process sees. Every session is addressed diff --git a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts index 140be5e1c39b..aa2b2da15486 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-hold.test.ts @@ -26,6 +26,7 @@ import { RpcDispatcher } from '../dispatcher' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { agentSessionFailureFact } from '../../../../shared/agent-session-failure' import { agentSessionFailureWords } from '../../../../shared/agent-session-failure-words' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const CONNECTION = 'connection-1' const CLIENT = { @@ -93,7 +94,7 @@ beforeEach(async () => { answerPrompt: async () => undefined, setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', now: () => NOW diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index f76b3ae6cb74..9bd7b1fade0e 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts @@ -33,6 +33,7 @@ import { SESSION_TAB_METHODS } from './session-tabs' import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session' import { commitStructuredAgentSessionCreate } from './structured-agent-session-create' import { closeStructuredAgentSessionChild } from '../../structured-agent-session-close' +import { openTestJournalHostDatabase } from '../../../native-chat/agent-session-journal/journal-host-database-test-support' const WORKTREE = `id:${HOST_TEST_LOCATION.workspaceId}` const SOURCE_TAB = `structured-agent-session-${HOST_TEST_SESSION}` @@ -93,7 +94,7 @@ async function openHost(): Promise { host = new StructuredAgentSessionHost({ store, adapter: providerAdapter(), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, mintSpawnToken: () => `spawn-${acquisitions}` diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 9cfe31251b6e..170348c961a8 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -1,5 +1,9 @@ import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' import { watchRuntimeMetadataOwnership } from '../runtime-metadata-ownership-watch' +import { + claimStructuredAgentSessionJournal, + onStructuredAgentSessionJournalOwned +} from '../structured-agent-session-journal-ownership' import type { RpcTransport } from '../rpc/transport' import { UnixSocketTransport } from '../rpc/unix-socket-transport' import { WebSocketTransport } from '../rpc/ws-transport' @@ -109,8 +113,29 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { this.activeTransports = activeTransports this.transports = transportsMeta + if (!this.ownsStructuredChats()) { + // Why: only the process owning this profile's chats is discoverable; the owner quitting + // hands the lock here, and this publishes then. + this.stopWaitingForJournalOwnership = onStructuredAgentSessionJournalOwned(() => { + this.stopWaitingForJournalOwnership?.() + this.stopWaitingForJournalOwnership = null + if (this.activeTransports.length === 0) { + return + } + try { + this.publishMetadata() + } catch (error) { + console.error( + '[runtime] Publishing runtime metadata after taking chat ownership failed:', + error + ) + } + }) + return + } + try { - this.writeMetadata() + this.publishMetadata() } catch (error) { // Why: a runtime that can't publish metadata is invisible to the CLI — close transports rather than run undiscoverable. this.activeTransports = [] @@ -118,7 +143,25 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) throw error } + } + + /** Taken before `orca-runtime.json` is written: a process that does not own the chats never + * advertises itself as the one that does. */ + private ownsStructuredChats(): boolean { + if (this.journalStateDirectory === undefined) { + return true + } + try { + return claimStructuredAgentSessionJournal(this.journalStateDirectory) !== null + } catch (error) { + // An unreadable lock file must not stop the runtime; chats report it on their own open. + console.error('[runtime] Claiming the chat journal owner lock failed:', error) + return true + } + } + private publishMetadata(): void { + this.writeMetadata() this.metadataOwnershipWatch = watchRuntimeMetadataOwnership({ userDataPath: this.userDataPath, ownedPid: this.pid, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts index 4923dec1dec1..839ad2bce297 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts @@ -50,6 +50,12 @@ export type OrcaRuntimeRpcServerOptions = { */ pinnedBindHost?: string webClientRoot?: string + /** + * The state directory whose structured chats this runtime serves. When set, `orca-runtime.json` + * is published only once this process holds that directory's chat journal owner lock, so the + * CLI is never pointed at a process that does not own the chats. + */ + journalStateDirectory?: string // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). keepaliveIntervalMs?: number longPollCap?: number diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index f79834fbcc23..333245d5bdcd 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -22,6 +22,8 @@ export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { this.transports = [] this.metadataOwnershipWatch?.stop() this.metadataOwnershipWatch = null + this.stopWaitingForJournalOwnership?.() + this.stopWaitingForJournalOwnership = null this.mobileSocketWiring = null this.detachWebSocketWiring = null const stopResults = await Promise.allSettled( diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index e7f56ceed13c..0f1ac384c658 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -41,6 +41,8 @@ export class RuntimeRpcState { protected readonly exposeNetworkByDefault: boolean protected readonly pinnedBindHost: string | null protected readonly webClientRoot: string | undefined + protected readonly journalStateDirectory: string | undefined + protected stopWaitingForJournalOwnership: (() => void) | null = null // Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once. protected wsBoundHost: string | null = null // Why: STA-2370 — in-flight widen so concurrent pairing requests share a single rebind. @@ -103,6 +105,7 @@ export class RuntimeRpcState { exposeNetworkByDefault = false, pinnedBindHost, webClientRoot, + journalStateDirectory, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP, metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS, @@ -119,6 +122,7 @@ export class RuntimeRpcState { this.exposeNetworkByDefault = exposeNetworkByDefault this.pinnedBindHost = pinnedBindHost ?? null this.webClientRoot = webClientRoot + this.journalStateDirectory = journalStateDirectory this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap this.metadataOwnershipPollMs = metadataOwnershipPollMs diff --git a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts index 7b22a45d09d5..b99a64548d3e 100644 --- a/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts +++ b/src/main/runtime/runtime-worktree-agent-rows-verdict.test.ts @@ -12,7 +12,7 @@ import type { } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' import { indexedStatusFeedSession } from '../native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' @@ -66,7 +66,7 @@ async function openJournal(): Promise { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) } diff --git a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts index 27d5ba48b299..f9effa9bf17e 100644 --- a/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts +++ b/src/main/runtime/runtime-worktree-structured-agent-rows-liveness.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { StructuredAgentSessionStatusFeed } from '../native-chat/agent-session-wire/structured-agent-session-status-feed' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { AgentSessionStatusSummary } from '../../shared/agent-session-wire' import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { AgentHookServer, _internals } from '../agent-hooks/server' @@ -66,7 +66,7 @@ async function awaitingApproval() { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) await journal.appendItem( { ...IDENTITY, ordinal: 1 }, diff --git a/src/main/runtime/structured-agent-session-integration-replay.test.ts b/src/main/runtime/structured-agent-session-integration-replay.test.ts index a4e2500ffc46..fa0c07191eac 100644 --- a/src/main/runtime/structured-agent-session-integration-replay.test.ts +++ b/src/main/runtime/structured-agent-session-integration-replay.test.ts @@ -23,8 +23,7 @@ import { } from '../../shared/protocol-version' import type { AgentJournalRenderItem } from '../../shared/agent-session-journal-types' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' @@ -384,7 +383,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + stateDirectory: root }) expect(reopened.snapshot().items.map(textOf)).toContain('Buffered while the journal opens.') expect( diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index 1b7136a996df..e38974240c87 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -30,10 +30,9 @@ import type { } from '../../shared/agent-session-wire' import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import { journalDirectoryFor } from '../native-chat/agent-session-journal/journal-paths' import { importLegacyTranscriptIntoJournal } from '../native-chat/agent-session-journal/journal-legacy-import' import type { AgentSessionJournal } from '../native-chat/agent-session-journal/journal-store' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-host-database-test-support' import type { OrcaRuntimeService } from './orca-runtime' import type { RpcRequest, RpcResponse } from './rpc/core' import { RpcDispatcher } from './rpc/dispatcher' @@ -410,7 +409,7 @@ describe('a structured codex session over agentSession.*', () => { } const journal = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + stateDirectory: root }) const rollout = join(root, 'legacy-rollout.jsonl') await writeFile( @@ -428,6 +427,8 @@ describe('a structured codex session over agentSession.*', () => { fence: 0, options: { filePath: rollout } }) + // The previous process exits, and its owner lock with it. + await journals.closeAll() const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( 'agentSession.create', @@ -827,7 +828,7 @@ describe('a structured codex session over agentSession.*', () => { } const reopened = await journals.open({ identity, - journalDir: journalDirectoryFor(root, identity) + stateDirectory: root }) expect(reopened.snapshot().items.map(textOf)).toContain('Final text before shutdown.') expect( diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts new file mode 100644 index 000000000000..e81fbd7ea189 --- /dev/null +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -0,0 +1,217 @@ +// One Orca process owns a profile's structured chats. Another on the same profile is refused +// with words that say what to do, is never advertised to the CLI, and takes over when the owner +// quits. An owner whose journal will not open refuses every chat and leaves the file alone. + +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' +import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { + holdJournalOwnerLockInChild, + probeJournalOwnerLockInChild, + type JournalOwnerLockHolder +} from '../native-chat/agent-session-journal/journal-owner-lock-test-support' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../native-chat/agent-session-journal/journal-open-failure' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import Database from '../sqlite/sync-database' +import { OrcaRuntimeService } from './orca-runtime' +import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' +import type { RpcContext } from './rpc/core' +import { readRuntimeMetadata } from './runtime-metadata' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { + JOURNAL_OWNER_REFUSAL_MESSAGES, + releaseStructuredAgentSessionJournal, + setJournalOwnerProcessKind, + type JournalOwnerProcessKind +} from './structured-agent-session-journal-ownership' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let root: string +let holder: JournalOwnerLockHolder | null = null + +// An in-process caller: the same build as the host, so the gate asks it for no capability. +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. +const IN_PROCESS = {} as RpcContext + +function install(): ReturnType { + return ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + reapOrphanChildren: async () => [], + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) +} + +/** The refusal as the gate throws it for every structured request. */ +function gateRefusal(): { reason: unknown; message: string } { + try { + requireStructuredCleanupHost(IN_PROCESS) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { reason: error.refusal.details?.reason, message: error.refusal.message } + } + throw error + } + throw new Error('the gate admitted the request') +} + +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-ownership-')) +}) + +afterEach(async () => { + await holder?.kill() + holder = null + await stopStructuredAgentSessionRuntime().catch(() => undefined) + releaseStructuredAgentSessionJournal() + setJournalOwnerProcessKind('packaged') + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('a second process on the same profile', () => { + // T-dev-refusal, in its dev, packaged and orcad variants. + it.each(['dev-desktop', 'packaged', 'orcad'])( + 'is refused with the %s words, and never opens the database', + async (kind) => { + holder = await holdJournalOwnerLockInChild(root) + setJournalOwnerProcessKind(kind) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(install()).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + message: JOURNAL_OWNER_REFUSAL_MESSAGES[kind], + details: { reason: 'journalUnavailable' } + } + }) + expect(gateRefusal()).toEqual({ + reason: 'journalUnavailable', + message: JOURNAL_OWNER_REFUSAL_MESSAGES[kind] + }) + expect(getStructuredAgentSessionHost()).toBeNull() + expect(existsSync(journalDatabasePath(root))).toBe(false) + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('agent-session-journal.owner; structured chats are read-refused') + ) + } + ) + + // T-B1 / N-R3.3: when the owner dies, the refused process takes the lock itself and its next + // request runs the full install — not a flag flip. + it('takes over once the owner dies, with a full install', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalUnavailable' } } + }) + + await holder.kill() + holder = null + + // Its retry took the lock: no process refuses it any more, and nothing is installed yet. + await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) + await expect(install()).resolves.toBeDefined() + expect(getStructuredAgentSessionHost()).not.toBeNull() + expect(existsSync(journalDatabasePath(root))).toBe(true) + expect(await probeJournalOwnerLockInChild(root)).toBe('refused') + }) + + // Discovery points the CLI only at the process that owns the chats. + it('is not published to the CLI until it owns the chats', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath: root, + journalStateDirectory: root + }) + try { + await server.start() + expect(readRuntimeMetadata(root)).toBeNull() + + await holder.kill() + holder = null + await vi.waitFor(() => expect(readRuntimeMetadata(root)?.pid).toBe(process.pid), { + timeout: 10_000 + }) + } finally { + await server.stop() + } + }) +}) + +describe('the owner, when its journal will not open', () => { + // T-corrupt-open: the error surfaces, every chat says it cannot be loaded, and nothing is + // renamed, deleted or rebuilt. + it('refuses every chat and leaves a damaged file exactly as it is', async () => { + const path = journalDatabasePath(root) + await writeFile(path, 'not a database '.repeat(512)) + const before = await digest(path) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(install()).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + expect(await digest(path)).toBe(before) + expect(existsSync(`${path}-wal`)).toBe(false) + expect(existsSync(`${path}-shm`)).toBe(false) + + // Once a person moves the file aside, the next request installs. + await unlink(path) + await expect(install()).resolves.toBeDefined() + expect(gateRefusal).toThrow('the gate admitted the request') + }) + + // T5: a newer build's database is refused as one that can clear, and left byte-identical. + it('refuses a database a newer Orca wrote, and leaves it byte-identical', async () => { + const path = journalDatabasePath(root) + const seeded = new Database(path) + seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + seeded.close() + const before = await digest(path) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(install()).rejects.toMatchObject({ + refusal: { message: JOURNAL_NEWER_SCHEMA_MESSAGE, details: { reason: 'journalUnavailable' } } + }) + expect(gateRefusal()).toEqual({ + reason: 'journalUnavailable', + message: JOURNAL_NEWER_SCHEMA_MESSAGE + }) + expect(await digest(path)).toBe(before) + }) +}) + +describe('releasing ownership', () => { + it('lets another process take the chats only after a clean stop', async () => { + await install() + expect(await probeJournalOwnerLockInChild(root)).toBe('refused') + + await stopStructuredAgentSessionRuntime() + + expect(await probeJournalOwnerLockInChild(root)).toBe('acquired') + }) +}) diff --git a/src/main/runtime/structured-agent-session-journal-ownership.ts b/src/main/runtime/structured-agent-session-journal-ownership.ts new file mode 100644 index 000000000000..612e5c87c5f2 --- /dev/null +++ b/src/main/runtime/structured-agent-session-journal-ownership.ts @@ -0,0 +1,131 @@ +// Whether THIS process owns its profile's structured chats. +// +// The claim is taken at runtime start, before `orca-runtime.json` is published, so a process that +// does not own the chats never advertises itself as the one that does. A process that cannot take +// it serves every structured-chat request a refusal naming what to do, and keeps retrying: the +// owner quitting is what clears it. One claim per process; a claim on another state directory +// releases the previous one first. + +import { resolve } from 'node:path' +import { + agentSessionRefusalError, + type AgentSessionRefusalError +} from '../../shared/agent-session-wire-refusals' +import { + JOURNAL_OWNER_LOCK_FILE, + retryJournalOwnerLock, + tryAcquireJournalOwnerLock, + type JournalOwnerLock +} from '../native-chat/agent-session-journal/journal-owner-lock' + +/** Which way the refused process can be told to get out of the way. */ +export type JournalOwnerProcessKind = 'dev-desktop' | 'packaged' | 'orcad' + +export const JOURNAL_OWNER_REFUSAL_MESSAGES: Record = { + 'dev-desktop': + 'Chats are open in another Orca window using this profile. Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).', + packaged: 'Chats are open in another Orca process using this profile. Quit it to use chats here.', + orcad: + 'Chats are open in another Orca process using this data folder. Stop it, or give this one its own folder (ORCA_USER_DATA).' +} + +type Refused = { stateDirectory: string; retry: { cancel: () => void } } + +let processKind: JournalOwnerProcessKind = 'packaged' +let held: JournalOwnerLock | null = null +let refused: Refused | null = null +let installRefusal: AgentSessionRefusalError | null = null +const ownedListeners = new Set<() => void>() + +export function setJournalOwnerProcessKind(kind: JournalOwnerProcessKind): void { + processKind = kind +} + +/** The held lock for `stateDirectory`, or null while another process holds it. */ +export function claimStructuredAgentSessionJournal( + stateDirectory: string +): JournalOwnerLock | null { + const directory = resolve(stateDirectory) + if (held && held.stateDirectory === directory) { + return held + } + if (refused?.stateDirectory === directory) { + return null + } + // A profile switch: the old claim goes before the new one is taken. + releaseStructuredAgentSessionJournal() + const lock = tryAcquireJournalOwnerLock(directory) + if (lock) { + adopt(lock) + return lock + } + console.warn( + `[structured-agent-session] another process owns ${directory}/${JOURNAL_OWNER_LOCK_FILE}; structured chats are read-refused in this process` + ) + refused = { + stateDirectory: directory, + retry: retryJournalOwnerLock({ + stateDirectory: directory, + onAcquired: adopt, + onError: (error) => + console.warn( + '[structured-agent-session] retrying the chat journal owner lock failed', + error + ) + }) + } + return null +} + +/** What every structured-chat request gets while another process owns the chats. */ +export function structuredAgentSessionJournalOwnerRefusal(): AgentSessionRefusalError | null { + if (!refused) { + return null + } + return agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalUnavailable' }, + JOURNAL_OWNER_REFUSAL_MESSAGES[processKind] + ) +} + +/** Why structured chats are refused in this process, or null when nothing refuses them. */ +export function structuredAgentSessionHostRefusal(): AgentSessionRefusalError | null { + return structuredAgentSessionJournalOwnerRefusal() ?? installRefusal +} + +/** Set when the owner could not open its journal database; cleared by a later install. The + * install retries on the next call, so a refusal that can clear does. */ +export function recordStructuredAgentSessionHostInstallRefusal( + refusal: AgentSessionRefusalError | null +): void { + installRefusal = refusal +} + +/** Told when a refused claim is finally granted, so discovery can publish this process. */ +export function onStructuredAgentSessionJournalOwned(listener: () => void): () => void { + ownedListeners.add(listener) + return () => ownedListeners.delete(listener) +} + +/** Last, after the journal database is closed. Also stops a refused claim's retry. */ +export function releaseStructuredAgentSessionJournal(): void { + refused?.retry.cancel() + refused = null + const lock = held + held = null + lock?.release() +} + +function adopt(lock: JournalOwnerLock): void { + refused?.retry.cancel() + refused = null + held = lock + for (const listener of ownedListeners) { + try { + listener() + } catch (error) { + console.warn('[structured-agent-session] announcing chat journal ownership failed', error) + } + } +} diff --git a/src/main/runtime/structured-agent-session-runtime-teardown.ts b/src/main/runtime/structured-agent-session-runtime-teardown.ts index 90f4ff92c4bc..47d93e028307 100644 --- a/src/main/runtime/structured-agent-session-runtime-teardown.ts +++ b/src/main/runtime/structured-agent-session-runtime-teardown.ts @@ -6,10 +6,13 @@ import type { AgentSessionResumeTrigger } from '../../shared/agent-session-resume-marker' import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' export type InstalledRuntime = { host: StructuredAgentSessionHost adapter: { closeAll(): Promise } + /** Closed last, once every conversation has drained into it. */ + journalDatabase: JournalHostDatabase /** Resolves after every observed adapter exit has published, and every * recovery callback it raised has settled. */ waitForRecovery: () => Promise @@ -65,10 +68,13 @@ export async function tearDownRuntime( } catch (error) { failures.push(error) } + if (failures.length === 0) { + // After every sink drained and every conversation settled: nothing writes after this. + installed.journalDatabase.close() + return + } if (failures.length === 1) { throw failures[0] } - if (failures.length > 1) { - throw new AggregateError(failures, 'structured agent-session runtime teardown failed') - } + throw new AggregateError(failures, 'structured agent-session runtime teardown failed') } diff --git a/src/main/runtime/structured-agent-session-runtime.test.ts b/src/main/runtime/structured-agent-session-runtime.test.ts index 7749fc3c25b0..f0e57c224863 100644 --- a/src/main/runtime/structured-agent-session-runtime.test.ts +++ b/src/main/runtime/structured-agent-session-runtime.test.ts @@ -2,9 +2,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../shared/agent-session-journal-types' -import { agentSessionJournalCloseRetries } from '../native-chat/agent-session-journal/journal-close-retry' -import { createTrackedJournalOpener } from '../native-chat/agent-session-journal/journal-store-test-open' import type { AgentSessionClaimStatus, AgentSessionExecutionLocation, @@ -321,70 +318,3 @@ describe('structured agent-session runtime install', () => { } }) }) - -// A stop whose teardown fails must not forget the runtime it was tearing down. -// `installing` is cleared either way so nothing new attaches, but the host keeps -// every journal whose close rejected, and this module slot is the only handle -// onto that host once it is gone. -describe('a teardown that fails is retried by the next stop', () => { - const JOURNAL_IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-teardown-retry', - workspaceId: 'ws-1', - hostId: HOST_ID, - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - } - const journals = createTrackedJournalOpener() - let directory: string | null = null - - afterEach(async () => { - await agentSessionJournalCloseRetries.retryAll() - await journals.closeAll() - await stopStructuredAgentSessionRuntime().catch(() => undefined) - if (directory) { - await rm(directory, { recursive: true, force: true }) - directory = null - } - }) - - it('reports the failure, then releases the handle on the following stop', async () => { - directory = await mkdtemp(join(tmpdir(), 'orca-structured-runtime-')) - await ensureStructuredAgentSessionHost({ - stateDirectory: directory, - hostId: HOST_ID, - claimKeyId: 'key-1', - resolveWorkspacePath: async () => directory!, - resolveEnvironment: async () => ({}), - reapOrphanChildren: async () => [], - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) - }) - - const journalDir = join(directory, 'stubborn-journal') - const real = await journals.open({ identity: JOURNAL_IDENTITY, journalDir }) - let closeFailures = 2 - const flaky = new Proxy(real, { - get(target, property, receiver) { - if (property !== 'close') { - // oxlint-disable-next-line anti-slop/no-reflect-get -- Proxy `get` trap: only Reflect.get forwards a raw string|symbol key with the proxy receiver. - return Reflect.get(target, property, receiver) - } - return async () => { - if (closeFailures > 0) { - closeFailures -= 1 - throw new Error('close rejected') - } - await target.close() - } - } - }) - await agentSessionJournalCloseRetries.closeOrRetain(flaky) - - // The host's teardown runs the registry retry, so this stop surfaces it. - await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([journalDir]) - - // The retained runtime is what makes this a retry rather than a no-op. - await stopStructuredAgentSessionRuntime() - expect(agentSessionJournalCloseRetries.pendingDirectories).toEqual([]) - }) -}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index e6b458eb2602..e6311f748888 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -1,11 +1,15 @@ // Where the structured agent-session wire becomes a live host on this runtime. // // Built on the first `agentSession.*` call rather than at startup: the record -// store and the journals live under the profile's user-data path, which is not +// store and the journal live under the profile's user-data path, which is not // final until Electron is ready, and a runtime that never serves a structured // session should not pay for a store it will never read. The slot the RPC layer // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. +// +// Only the process holding the profile's journal owner lock installs a host. Any +// other process, and an owner whose journal will not open, installs none and +// answers every structured request with the refusal that says why. import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' @@ -37,6 +41,14 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { + claimStructuredAgentSessionJournal, + recordStructuredAgentSessionHostInstallRefusal, + releaseStructuredAgentSessionJournal, + structuredAgentSessionJournalOwnerRefusal +} from './structured-agent-session-journal-ownership' import { agentSessionStorePath } from './agent-session-record-store-file' import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' import { @@ -54,8 +66,7 @@ import { type RuntimeAgentAccountHomeResolver } from './structured-agent-model-catalog-wiring' -/** Sibling of the journal tree rather than inside it: one file adjudicates every - * session's lease, while a journal is per session. */ +/** Beside the journal database: one file adjudicates every session's lease. */ const RECORD_STORE_DIR_NAME = 'agent-sessions' export function hasPersistedStructuredAgentSessionStore( @@ -67,7 +78,7 @@ export function hasPersistedStructuredAgentSessionStore( } export type StructuredAgentSessionRuntimeDeps = { - /** Host state root. The record store and the journal tree both hang off it. */ + /** Host state root. The record store and the journal database both hang off it. */ stateDirectory: string /** Execution host this runtime *is*. A record pinned elsewhere is not ours to * probe and not ours to spawn for. */ @@ -121,9 +132,8 @@ export const CLAUDE_STRUCTURED_AUTH_POLICY_REQUIRED = /** * Runtimes whose teardown did not finish. `installing` is cleared regardless so * nothing new attaches, but dropping the runtime as well would strand every - * journal the host retained for a retry: `tearDownStructuredAgentSessionHost` - * deliberately keeps a failed close indexed, and only a later stop through this - * same runtime can reach those entries again. + * conversation the host kept indexed for a retry — and the journal connection, + * which closes only once they are settled — so a later stop retries them here. */ const pendingTeardown = new Set() @@ -153,8 +163,9 @@ export async function waitForStructuredAgentSessionRecovery(): Promise { * test isolation take the same path, so neither can leave a live app-server. * * A teardown that fails is RETRIED by the next stop rather than forgotten: the - * host keeps every journal whose close rejected, and this is the only handle - * onto that host once the module slot is cleared. */ + * host keeps every conversation it could not settle, and this is the only handle + * onto that host once the module slot is cleared. The owner lock goes last, and + * only once nothing is left to retry. */ export async function stopStructuredAgentSessionRuntime(options?: { trigger?: AgentSessionResumeTrigger }): Promise { @@ -178,6 +189,9 @@ export async function stopStructuredAgentSessionRuntime(options?: { } } await agentModelCatalogStore.flushPersistence() + if (failures.length === 0 && pendingTeardown.size === 0) { + releaseStructuredAgentSessionJournal() + } if (failures.length === 1) { throw failures[0] } @@ -193,12 +207,19 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { try { @@ -300,7 +321,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { adapter: new StructuredAgentSessionAdapterRouter({ claude: adapter, codex: adapter }, () => adapter.closeAll() ), - journalRoot: directory, + journalDatabase: openTestJournalHostDatabase(directory), claimKeyId: 'key', now: () => HOST_TEST_NOW, probeOwner: async () => ({ outcome: 'exit-observed' }) diff --git a/src/main/sqlite/sqlite-free-page-reclaim.ts b/src/main/sqlite/sqlite-free-page-reclaim.ts new file mode 100644 index 000000000000..2da5d9dd6018 --- /dev/null +++ b/src/main/sqlite/sqlite-free-page-reclaim.ts @@ -0,0 +1,18 @@ +// Handing freed pages back to the filesystem under `auto_vacuum = INCREMENTAL`. +// +// That mode holds every freed page on the freelist until something asks for it back; nothing asks +// on its own, so a database that deletes and never reclaims only grows. Asking for a whole purge's +// worth at once is one long stall (40 ms per 22 MB freed, measured), so each call is capped. + +import type SyncDatabase from './sync-database' + +export const RECLAIM_PAGES_PER_STEP = 2000 + +/** One bounded step, run to completion. Returns the pages still on the freelist. */ +export function reclaimFreePagesStep( + db: SyncDatabase, + maxPages: number = RECLAIM_PAGES_PER_STEP +): number { + db.pragma(`incremental_vacuum(${maxPages})`) + return Number(db.pragma('freelist_count', { simple: true }) ?? 0) +} diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index b3df23e5861c..b91ef498d9f9 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -7,6 +7,7 @@ import { migrateMobilePairingDataToCanonicalUserDataPath } from '../persistence' import { OrcaRuntimeRpcServer } from '../runtime/runtime-rpc' +import { setJournalOwnerProcessKind } from '../runtime/structured-agent-session-journal-ownership' import { registerMobileHandlers } from '../ipc/mobile' import { getLocalPtyProvider, registerHeadlessPtyRuntime } from '../ipc/pty' import { LocalPtyProvider } from '../providers/local-pty-provider' @@ -74,6 +75,7 @@ function installRuntimeRpc( } // Why: pin dev to 6769 so `pnpm dev` doesn't race packaged Orca on 6768 and fall back to a random port, breaking deterministic mobile pairing/repro (STA-1511). const devWsPort = is.dev && !isE2E ? 6769 : undefined + setJournalOwnerProcessKind(is.dev && !serveOptions ? 'dev-desktop' : 'packaged') const runtimeRpc = new OrcaRuntimeRpcServer({ runtime, // Why: mobile pairing needs the stable pre-setName() path (getCanonicalUserDataPath), not a late app.getPath('userData') that drops paired devices across restarts. @@ -91,7 +93,8 @@ function installRuntimeRpc( preferPinnedWsPort: true } : {}), - webClientRoot: getBundledWebClientRoot() + webClientRoot: getBundledWebClientRoot(), + journalStateDirectory: getProfileUserDataPath() }) state.runtimeRpc = runtimeRpc registerMobileHandlers(runtimeRpc, { diff --git a/src/shared/structured-agent-session-saved-status.ts b/src/shared/structured-agent-session-saved-status.ts new file mode 100644 index 000000000000..7c51ef3c8f17 --- /dev/null +++ b/src/shared/structured-agent-session-saved-status.ts @@ -0,0 +1,25 @@ +// A chat's last settled listing status, saved beside its journal so a restarted host can list it +// without replaying the journal. +// +// Only an idle or no-turn projection is saved, keyed by the journal position it was computed at. +// A reader trusts it only where the journal still stands exactly there; anything else is a miss +// and the journal is opened, so a saved copy can go stale but can never be shown stale. + +import type { StructuredAgentSessionStatusProjection } from './structured-agent-session-projection' + +/** Bump whenever the projection or the reducer changes what an idle or no-turn chat projects. */ +export const STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION = 1 + +export type StructuredAgentSessionSavedStatus = { + v: number + projection: StructuredAgentSessionStatusProjection + /** The journal's `lastActivityAt()` at that position, which dates the row. */ + lastActivityAt: number +} + +/** Whether a projection is one a saved copy may hold: settled, so no fence can change it. */ +export function isSavableStructuredAgentSessionProjection( + projection: StructuredAgentSessionStatusProjection +): boolean { + return projection.status === null || projection.status === 'idle' +} diff --git a/tests/e2e/codex-child-approval-activity-row.unit.test.ts b/tests/e2e/codex-child-approval-activity-row.unit.test.ts index 71bb0fddce18..dbb2a5d8e9cb 100644 --- a/tests/e2e/codex-child-approval-activity-row.unit.test.ts +++ b/tests/e2e/codex-child-approval-activity-row.unit.test.ts @@ -22,7 +22,7 @@ import type { Tab } from '../../src/shared/tab-types' import type { AppState } from '../../src/renderer/src/store/types' import { createCodexJournalTranslator } from '../../src/main/codex/codex-structured-journal-translation' import { CODEX_COMMAND_APPROVAL_METHOD } from '../../src/main/codex/codex-structured-prompt-replies' -import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { createDeferredStructuredAgentSessionEventSink } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-event-sink' import { StructuredAgentSessionStatusFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-status-feed' import { indexedStatusFeedSession } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-test-session' @@ -140,7 +140,7 @@ async function openHost() { providerHandle: { kind: 'codex', threadId: CODEX_THREAD } }, now: tick, - journalDir: join(root, SESSION) + stateDirectory: join(root, SESSION) }) const feed = new StructuredAgentSessionStatusFeed({ sessions: new Map([ diff --git a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts index ec7b2b9ec548..a476833f4809 100644 --- a/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts +++ b/tests/e2e/cross-version-wire/cross-version-agent-session-wire.unit.test.ts @@ -59,6 +59,7 @@ import { type RpcClientIdentity, type RpcReply } from './versioned-agent-session-wire' +import { openTestJournalHostDatabase } from '../../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' // Why: a cold CI run extracts the baseline checkout before the first pairing. const SUITE_TIMEOUT_MS = 180_000 @@ -463,7 +464,7 @@ describe('cross-version structured agent sessions', () => { answerPrompt: async () => undefined, setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-vault', now: () => NOW @@ -672,7 +673,7 @@ describe('cross-version structured agent sessions', () => { const host = new StructuredAgentSessionHost({ store, adapter: adapter(), - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => `spawn-${generation}`, // The provider died with the host that spawned it, which is what makes diff --git a/tests/e2e/structured-agent-session-read-owner.unit.test.ts b/tests/e2e/structured-agent-session-read-owner.unit.test.ts index 4e64a047efcd..dafa6e8089df 100644 --- a/tests/e2e/structured-agent-session-read-owner.unit.test.ts +++ b/tests/e2e/structured-agent-session-read-owner.unit.test.ts @@ -17,7 +17,7 @@ import { hasUnansweredStructuredAgentSessionDispatch, projectStructuredAgentSessionStatus } from '../../src/shared/structured-agent-session-projection' -import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-store-test-open' +import { createTrackedJournalOpener } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from '../../src/main/native-chat/agent-session-wire/agent-session-history-page' import { AgentSessionSubscribers } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-subscribers' import { StructuredAgentSessionStatusFeed } from '../../src/main/native-chat/agent-session-wire/structured-agent-session-status-feed' @@ -57,7 +57,7 @@ async function fixture() { agent: 'codex', providerHandle: { kind: 'codex', threadId: 'thread-1' } }, - journalDir: join(root, 'journal') + stateDirectory: join(root, 'journal') }) async function appendOutput(index: number) { await journal.appendItem( diff --git a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts index 3d8858b3658b..dff6b466ec74 100644 --- a/tests/e2e/structured-chat-owner-status-activation.unit.test.ts +++ b/tests/e2e/structured-chat-owner-status-activation.unit.test.ts @@ -23,6 +23,7 @@ import { useAppStore } from '../../src/renderer/src/store' import { runWorktreeAgentActivationGate } from '../../src/renderer/src/lib/worktree-agent-activation-gate' import { readWorktreeStructuredActivationInventory } from '../../src/renderer/src/lib/worktree-agent-structured-inventory' import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-types' +import { openTestJournalHostDatabase } from '../../src/main/native-chat/agent-session-journal/journal-host-database-test-support' const WORKTREE = 'repo-1::/workspace/repo' @@ -53,7 +54,7 @@ function openHost(): void { answerPrompt: async () => undefined, setOption: async () => undefined }, - journalRoot: root, + journalDatabase: openTestJournalHostDatabase(root), claimKeyId: 'key-1', mintSpawnToken: () => 'spawn-a', idleSweep: { intervalMs: 5 }, From 73795f6bd600bd2a7c58c69fefb49f8c019da32d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:22:44 -0700 Subject: [PATCH 02/66] perf(native-chat): key journal rows by block so one chat's rows sit together Each chat's live epoch owns a block of row ids, block * 2^32 + seq, so a chat's rows share leaf pages with nobody else's, a replay is one range scan, and replacing or rewinding a chat deletes one contiguous range. Measured on the largest real chat (61 MB) beside 19 interleaved peers: 39 ms and 7.5 MB of WAL, against 214 ms and 102 MB for a (session_id, epoch, seq) key. - Ids are computed in Number arithmetic, never bitwise. A sequence is refused outside [1, 2^32) and a block at 2^21, which keeps every id below 2^53. - A replace, roll or import allocates a fresh block, moves the chat's pointer, and deletes the old block in the same transaction, so no orphan block exists. - The listing status write moves into its own writer beside the column. --- .../journal-block-replace.test.ts | 81 +++++++++ .../journal-corruption-repair.test.ts | 29 ++- .../journal-database-schema.ts | 15 +- .../journal-database.test.ts | 98 ++++++---- .../journal-epoch-replacement.test.ts | 9 +- .../journal-epoch-replacement.ts | 30 ++-- .../journal-epoch-rollover.ts | 31 ++-- .../journal-file-format-remnant.test.ts | 41 ++--- .../journal-host-database-test-support.ts | 91 ++++++++++ .../journal-host-database.test.ts | 6 +- .../agent-session-journal/journal-open.ts | 18 +- .../journal-per-session-import.test.ts | 9 +- .../journal-per-session-import.ts | 23 ++- .../journal-repair-marker.ts | 13 +- .../journal-row-context-usage.test.ts | 16 +- .../journal-row-schema-version.test.ts | 9 +- .../journal-row-table.ts | 170 +++++++++++------- .../journal-row-writer.test.ts | 20 ++- .../journal-row-writer.ts | 3 +- .../journal-session-status.test.ts | 5 +- .../journal-session-status.ts | 59 ++++++ .../journal-store-collaborators.ts | 2 + .../journal-store-open.ts | 1 + .../journal-store-restore.ts | 2 +- .../journal-store-schema.test.ts | 15 +- .../journal-store.test.ts | 13 +- .../agent-session-journal/journal-store.ts | 56 ++---- .../journal-streaming-replay.test.ts | 16 +- ...ession-history-forward-read-budget.test.ts | 29 ++- .../agent-session-history-page.test.ts | 6 +- .../agent-session-journal-recovery.test.ts | 32 ++-- ...ructured-agent-session-subscribers.test.ts | 6 +- ...tured-agent-session-wire-admission.test.ts | 6 +- .../structured-agent-session-at-rest.test.ts | 9 +- 34 files changed, 638 insertions(+), 331 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-block-replace.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts b/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts new file mode 100644 index 000000000000..312eca29b0d3 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts @@ -0,0 +1,81 @@ +// T-block: rewinding one chat among interleaved peers rewrites that chat's rows, not the file's. +// +// Written only against the store, so the same case measures any key layout: under block keys a +// chat's rows are one contiguous range, and a replace frees that range; under a key whose table +// order follows insertion, the chat's rows are spread across every leaf its peers share. + +import { mkdtemp, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { journalDatabasePath } from './journal-host-database' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import type { AgentSessionJournal } from './journal-store' + +const PEERS = 19 +const ROWS_PER_CHAT = 600 +const TEXT = 'x'.repeat(500) + +let root: string +const journals = createTrackedJournalOpener() + +function identity(index: number): AgentSessionJournalIdentity { + return { + sessionId: `session-${index}`, + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: `thread-${index}` } + } +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-block-')) +}) + +afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +it('replaces one chat among 19 interleaved peers with a write-ahead log a fraction of its size', async () => { + // Seeding only: the case measures the replace, not ten thousand fsyncs before it. + openTestJournalHostDatabase(root).db.pragma('synchronous = OFF') + const chats: AgentSessionJournal[] = [] + for (let index = 0; index <= PEERS; index += 1) { + chats.push(await journals.open({ identity: identity(index), stateDirectory: root })) + } + // Interleaved, as concurrent chats write: every chat's row n lands before any chat's row n + 1. + for (let ordinal = 1; ordinal <= ROWS_PER_CHAT; ordinal += 1) { + for (const [index, chat] of chats.entries()) { + await chat.appendItem( + { provider: 'codex', threadId: `thread-${index}`, turnId: 'turn-1', ordinal }, + { kind: 'status', text: TEXT }, + { fence: 1 } + ) + } + } + const { db } = openTestJournalHostDatabase(root) + db.pragma('synchronous = FULL') + db.pragma('wal_autocheckpoint = 0') + db.pragma('wal_checkpoint(TRUNCATE)') + const walPath = `${journalDatabasePath(root)}-wal` + const total = db.prepare('SELECT sum(length(row_json)) AS total FROM journal_rows').get()?.total + const chatBytes = Number(total) / (PEERS + 1) + + await chats[0]!.replaceEpochItems('legacy_import', 1, [ + { + identity: { provider: 'codex', threadId: 'thread-0', turnId: 'turn-9', ordinal: 1 }, + body: { kind: 'status', text: 'rewound' } + } + ]) + + // Measured on this seed: block keys about 0.7x the chat's bytes; a key whose table order follows + // insertion about 6x, the whole file. The G2 rewind row measures the largest real chat. + const walBytes = (await stat(walPath)).size + expect(walBytes).toBeLessThan(chatBytes) +}, 120_000) 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 94d05ed94be1..9a72a9af1704 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 @@ -22,7 +22,10 @@ import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener, openTestJournalHostDatabase, - loadTestJournal + loadTestJournal, + liveTestJournalRows, + updateTestJournalRowJson, + deleteTestJournalRow } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -73,10 +76,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 +85,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) } @@ -108,7 +107,7 @@ describe('a malformed row', () => { await journal.appendItem(item(2), body('after the fault'), { fence: 1 }) 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() @@ -123,7 +122,7 @@ describe('a malformed row', () => { await journal.appendItem(item(1), body('later'), { fence: 1 }) 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() @@ -148,7 +147,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() @@ -168,7 +167,7 @@ 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() @@ -192,7 +191,7 @@ 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() @@ -227,7 +226,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() @@ -249,7 +248,7 @@ describe('a missing epoch row', () => { await journal.appendItem(item(0), body('anchor'), { fence: 1 }) 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() 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 d356e770d488..5747b6400171 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,7 +1,8 @@ // Table shape for the host's one chat journal database. // -// `journal_rows` is every chat's append-only log. `journal_sessions` names each chat's live epoch -// and is written only when that epoch changes, so an append is one INSERT. `journal_repairs` +// `journal_rows` is every chat's append-only log, keyed `block * 2^32 + seq` (journal-row-table.ts). +// `journal_sessions` names each chat's live epoch and the block its rows sit in, 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). `status_json` is the chat's last settled listing status, // trusted only where `status_seq` is still the tip of the live epoch. @@ -14,17 +15,15 @@ export const JOURNAL_DB_SCHEMA_VERSION = 1 export function createJournalTablesSql(): string { return ` CREATE TABLE IF NOT EXISTS 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) + id INTEGER PRIMARY KEY, + ts INTEGER NOT NULL, + row_json TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS 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 ); 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 8942ad972325..4818b76a5253 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 @@ -14,14 +14,20 @@ import { import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' import { + allocateJournalBlock, + deleteJournalBlock, deleteJournalRowSuffix, - deleteJournalSessionRows, insertJournalRow, iterateJournalEpochRows, + JOURNAL_BLOCK_LIMIT, + JOURNAL_SEQUENCE_LIMIT, + JournalKeySpaceError, + journalRowId, publishJournalSessionEpoch, readJournalRowsAfter, - readJournalSessionEpoch, - readJournalTip + readJournalSessionPointer, + readJournalTip, + type JournalBlockPointer } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' @@ -44,8 +50,8 @@ 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) +function rowsOf(db: Database.Database, pointer: JournalBlockPointer): number[] { + return [...iterateJournalEpochRows(db, pointer)].map((row) => row.seq) } async function digest(path: string): Promise { @@ -91,8 +97,8 @@ describe('the host journal database open', () => { // T5: a newer build's database is refused and left byte-identical. it('refuses a newer user_version without touching the file', async () => { const seeded = openJournalDatabase(dbPath) - publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') - insertJournalRow(seeded, 'session-1', epochRow(1)) + publishJournalSessionEpoch(seeded, SESSION, { epoch: 'epoch-1', block: 0 }) + insertJournalRow(seeded, 0, epochRow(1)) seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) seeded.close() const before = await digest(dbPath) @@ -113,44 +119,44 @@ describe('the host journal database open', () => { }) describe('journal row statements', () => { - it('serves replay, resume, the tip, suffix truncation and a chat-scoped discard', () => { + it('serves replay, resume, the tip, suffix truncation and a block discard', () => { const db = openJournalDatabase(dbPath) try { + const pointer = { epoch: 'epoch-1', block: 0 } + const other = { epoch: 'epoch-other', block: 1 } db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { - insertJournalRow(db, 'session-1', epochRow(seq)) + insertJournalRow(db, pointer.block, epochRow(seq)) } - insertJournalRow(db, 'session-2', epochRow(1, 'epoch-other')) - publishJournalSessionEpoch(db, SESSION, 'epoch-1') - publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-other') + insertJournalRow(db, other.block, epochRow(1, 'epoch-other')) + publishJournalSessionEpoch(db, SESSION, pointer) + publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, other) db.exec('COMMIT') - expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1') - expect(readJournalSessionEpoch(db, 'absent')).toBeNull() - expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3, 4, 5]) - expect(readJournalTip(db, 'session-1', 'epoch-1')).toBe(5) - expect(readJournalRowsAfter(db, 'session-1', 'epoch-1', 3).map((row) => row.seq)).toEqual([ - 4, 5 - ]) + expect(readJournalSessionPointer(db, 'session-1')).toEqual(pointer) + expect(readJournalSessionPointer(db, 'absent')).toBeNull() + expect(rowsOf(db, pointer)).toEqual([1, 2, 3, 4, 5]) + expect(readJournalTip(db, pointer.block)).toBe(5) + expect(readJournalRowsAfter(db, pointer, 3).map((row) => row.seq)).toEqual([4, 5]) - expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2) - expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3]) + expect(deleteJournalRowSuffix(db, pointer.block, 4)).toBe(2) + expect(rowsOf(db, pointer)).toEqual([1, 2, 3]) // Another chat in the same file is untouched by this chat's discard. - deleteJournalSessionRows(db, 'session-1') - expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) - expect(rowsOf(db, 'session-2', 'epoch-other')).toEqual([1]) + deleteJournalBlock(db, pointer.block) + expect(rowsOf(db, pointer)).toEqual([]) + expect(rowsOf(db, other)).toEqual([1]) } finally { db.close() } }) - it('refuses a duplicate sequence inside one epoch', () => { + it('refuses a duplicate sequence inside one block', () => { const db = openJournalDatabase(dbPath) try { - insertJournalRow(db, 'session-1', epochRow(1)) - expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() - insertJournalRow(db, 'session-1', epochRow(1, 'epoch-2')) + insertJournalRow(db, 0, epochRow(1)) + expect(() => insertJournalRow(db, 0, epochRow(1))).toThrow() + insertJournalRow(db, 1, epochRow(1)) } finally { db.close() } @@ -159,10 +165,10 @@ describe('journal row statements', () => { it('moves the epoch pointer in place and forgets the saved status with it', () => { const db = openJournalDatabase(dbPath) try { - publishJournalSessionEpoch(db, SESSION, 'epoch-1') + publishJournalSessionEpoch(db, SESSION, { epoch: 'epoch-1', block: 0 }) db.exec("UPDATE journal_sessions SET status_json = '{}', status_seq = 3") - publishJournalSessionEpoch(db, SESSION, 'epoch-2') - expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-2') + publishJournalSessionEpoch(db, SESSION, { epoch: 'epoch-2', block: 1 }) + expect(readJournalSessionPointer(db, 'session-1')).toEqual({ epoch: 'epoch-2', block: 1 }) expect( db .prepare('SELECT count(*) AS total, max(status_json) AS status FROM journal_sessions') @@ -174,6 +180,36 @@ describe('journal row statements', () => { }) }) +// T-block: the key's bounds, where Number arithmetic has to stay exact. +describe('block keys', () => { + it('keys the last sequence of the last block exactly, below 2^53', () => { + const db = openJournalDatabase(dbPath) + try { + const pointer = { epoch: 'epoch-1', block: JOURNAL_BLOCK_LIMIT - 1 } + const seq = JOURNAL_SEQUENCE_LIMIT - 1 + expect(journalRowId(pointer.block, seq)).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER) + insertJournalRow(db, pointer.block, epochRow(seq)) + insertJournalRow(db, pointer.block - 1, epochRow(seq)) + expect(readJournalRowsAfter(db, pointer, 0).map((row) => row.seq)).toEqual([seq]) + expect(readJournalTip(db, pointer.block)).toBe(seq) + } finally { + db.close() + } + }) + + it('refuses a sequence outside its block, and a block past the last', () => { + expect(() => journalRowId(0, JOURNAL_SEQUENCE_LIMIT)).toThrow(JournalKeySpaceError) + expect(() => journalRowId(0, 0)).toThrow(JournalKeySpaceError) + const db = openJournalDatabase(dbPath) + try { + publishJournalSessionEpoch(db, SESSION, { epoch: 'epoch-1', block: JOURNAL_BLOCK_LIMIT - 1 }) + expect(() => allocateJournalBlock(db)).toThrow(JournalKeySpaceError) + } finally { + db.close() + } + }) +}) + describe('schema creation', () => { it('publishes no table until the version bump commits with it', () => { const original = Database.prototype.pragma 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 2b4e93c2849f..34cb8908afa8 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 @@ -11,10 +11,11 @@ import type { import { journalDatabasePath, type JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch } from './journal-epoch-replacement' import type { JournalLoad } from './journal-open' -import { readJournalEpochRows, readJournalSessionEpoch } from './journal-row-table' +import { readJournalSessionEpoch } from './journal-row-table' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + readTestJournalRows } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -83,7 +84,7 @@ 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 () => { @@ -97,7 +98,7 @@ describe('journal epoch replacement', () => { ]) expect(journal.epoch).not.toBe(before) - expect(readJournalEpochRows(database.db, IDENTITY.sessionId, before)).toHaveLength(0) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, before)).toHaveLength(0) expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([ { kind: 'status', text: 'republished' } ]) 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 d4d738c8c920..7e231d2e7c33 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,9 +1,10 @@ // 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. The pages -// the old rows held go back to the filesystem after the commit. +// One transaction: discard the old block, insert the epoch row plus the +// replacement items into a fresh one, move the session projection, and retire +// any repair marker — this republished history is exactly what the marker was +// holding out for. The pages the old rows held go back to the filesystem after +// the commit. import type { AgentJournalItemBody, @@ -16,9 +17,11 @@ import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { - deleteJournalSessionRows, + allocateJournalBlock, + deleteJournalBlock, insertJournalRow, - publishJournalSessionEpoch + publishJournalSessionEpoch, + readJournalSessionPointer } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' import { assertJournalFence } from './journal-write-guards' @@ -65,19 +68,24 @@ export function replaceJournalEpoch(input: { } const { sessionId } = input.identity - input.database.transaction((db) => { - deleteJournalSessionRows(db, sessionId) + const block = input.database.transaction((db) => { + const retired = readJournalSessionPointer(db, sessionId) + const fresh = allocateJournalBlock(db) + if (retired) { + deleteJournalBlock(db, retired.block) + } clearJournalRepairMarker(db, sessionId) for (const row of rows) { - insertJournalRow(db, sessionId, row) + insertJournalRow(db, fresh, row) } - publishJournalSessionEpoch(db, input.identity, epoch) + publishJournalSessionEpoch(db, input.identity, { epoch, block: fresh }) + return fresh }) // 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 // live store writing into an epoch whose rows were just deleted. state.oldestSequence = 1 - input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) + input.onPublished({ state, block, readOnly: false, corrupt: false, malformedRows: 0 }) void input.database.reclaimFreePages() } 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 5ea2c8f9f845..9544b2e415fb 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 @@ -1,10 +1,10 @@ // Opening a new epoch. // -// One transaction: discard every row of the superseded epoch, insert the new -// epoch row at sequence 1, move the session projection onto it, and retire any -// repair marker the superseded epoch was carrying. Superseded rows are DELETED -// rather than retained — nothing would ever shed them — and the pages they held -// go back to the filesystem after the commit. +// One transaction: discard the superseded epoch's block, insert the new epoch +// row at sequence 1 of a fresh block, move the session projection onto it, and +// retire any repair marker the superseded epoch was carrying. Superseded rows +// are DELETED rather than retained — nothing would ever shed them — and the +// pages they held go back to the filesystem after the commit. import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -13,9 +13,11 @@ import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { - deleteJournalSessionRows, + allocateJournalBlock, + deleteJournalBlock, insertJournalRow, - publishJournalSessionEpoch + publishJournalSessionEpoch, + readJournalSessionPointer } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' @@ -42,11 +44,16 @@ export function publishNewEpoch(input: { } const { sessionId } = input.identity - input.database.transaction((db) => { - deleteJournalSessionRows(db, sessionId) + const block = input.database.transaction((db) => { + const retired = readJournalSessionPointer(db, sessionId) + const fresh = allocateJournalBlock(db) + if (retired) { + deleteJournalBlock(db, retired.block) + } clearJournalRepairMarker(db, sessionId) - insertJournalRow(db, sessionId, row) - publishJournalSessionEpoch(db, input.identity, input.epoch) + insertJournalRow(db, fresh, row) + publishJournalSessionEpoch(db, input.identity, { epoch: input.epoch, block: fresh }) + return fresh }) // COMMIT landed: on disk the superseded prefix is gone and this epoch is the @@ -55,6 +62,6 @@ export function publishNewEpoch(input: { const state = createJournalReducerState(sessionId, input.epoch) applyJournalRow(state, row) state.oldestSequence = 1 - input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) + input.onPublished({ state, block, readOnly: false, corrupt: false, malformedRows: 0 }) void input.database.reclaimFreePages() } 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 22b773c56dae..ca1d33662e17 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 @@ -18,7 +18,9 @@ import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener, openTestJournalHostDatabase, - loadTestJournal + loadTestJournal, + deleteTestJournalRow, + insertTestJournalRowJson } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -157,7 +159,7 @@ describe('a chat whose history is still in the pre-SQLite format', () => { // where this branch and a repair meet. const opened = openTestJournalHostDatabase(root) try { - opened.db.prepare('DELETE FROM journal_rows WHERE seq = ?').run(1) + deleteTestJournalRow(opened.db, IDENTITY.sessionId, 1) } finally { opened.close() } @@ -176,27 +178,22 @@ describe('a chat whose history is still in the pre-SQLite format', () => { const founded = await open() const epoch = founded.epoch await founded.close() - openTestJournalHostDatabase(root) - .db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - .run( - IDENTITY.sessionId, + insertTestJournalRowJson( + openTestJournalHostDatabase(root).db, + IDENTITY.sessionId, + 2, + JSON.stringify({ + v: 99, + kind: 'item', epoch, - 2, - 1, - 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' } - }) - ) + 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-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index 1442908d3720..c1394758d147 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -6,6 +6,18 @@ import { resolve } from 'node:path' import { JournalHostDatabase } from './journal-host-database' import { replayJournal, type JournalLoad } from './journal-open' +import type { JournalRow } from './journal-row-schema' +import { + allocateJournalBlock, + insertJournalRow, + iterateJournalEpochRows, + journalRowId, + publishJournalSessionEpoch, + readJournalSessionPointer, + type JournalBlockPointer, + type JournalStoredRow +} from './journal-row-table' +import type Database from '../../sqlite/sync-database' import { tryAcquireJournalOwnerLock, type JournalOwnerLock } from './journal-owner-lock' import type { AgentSessionJournal } from './journal-store' import type { AgentSessionJournalOptions } from './journal-store-contracts' @@ -75,3 +87,82 @@ export function createTrackedJournalOpener(): TrackedJournalOpener { 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 livePointer(db: Database.Database, sessionId: string): JournalBlockPointer { + const pointer = readJournalSessionPointer(db, sessionId) + if (!pointer) { + throw new Error(`no journal for ${sessionId}`) + } + return pointer +} + +/** Points the chat at a new epoch in a fresh block, as a publish would. */ +export function publishTestJournalEpoch( + db: Database.Database, + sessionId: string, + epoch: string +): void { + publishJournalSessionEpoch( + db, + { sessionId, workspaceId: 'ws-1' }, + { epoch, block: allocateJournalBlock(db) } + ) +} + +/** 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[] { + const pointer = readJournalSessionPointer(db, sessionId) + return pointer?.epoch === epoch ? [...iterateJournalEpochRows(db, pointer)] : [] +} + +/** The chat's rows of whichever epoch is live now. */ +export function liveTestJournalRows(db: Database.Database, sessionId: string): JournalStoredRow[] { + const pointer = readJournalSessionPointer(db, sessionId) + return pointer ? [...iterateJournalEpochRows(db, pointer)] : [] +} + +export function insertTestJournalRow( + db: Database.Database, + sessionId: string, + row: JournalRow +): void { + insertJournalRow(db, livePointer(db, sessionId).block, row) +} + +/** 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 +): void { + db.prepare('INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)').run( + journalRowId(livePointer(db, sessionId).block, seq), + 1, + rowJson + ) +} + +export function updateTestJournalRowJson( + db: Database.Database, + sessionId: string, + seq: number, + rowJson: string +): void { + db.prepare('UPDATE journal_rows SET row_json = ? WHERE id = ?').run( + rowJson, + journalRowId(livePointer(db, sessionId).block, seq) + ) +} + +export function deleteTestJournalRow(db: Database.Database, sessionId: string, seq: number): void { + db.prepare('DELETE FROM journal_rows WHERE id = ?').run( + journalRowId(livePointer(db, sessionId).block, 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 index 1aa04d57b259..f50dbc5255fd 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -15,9 +15,9 @@ import { journalDatabasePath } from './journal-host-database' import { closeTestJournalHostDatabases, createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + readTestJournalRows } from './journal-host-database-test-support' -import { readJournalEpochRows } from './journal-row-table' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -131,7 +131,7 @@ describe('quit', () => { closeTestJournalHostDatabases() const reopened = openTestJournalHostDatabase(root) expect( - readJournalEpochRows(reopened.db, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) + 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-open.ts b/src/main/native-chat/agent-session-journal/journal-open.ts index 595dcec6d691..77c9d58444ce 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -15,7 +15,8 @@ import { import { iterateJournalEpochRows, readJournalRowsAfter, - readJournalSessionEpoch + readJournalSessionPointer, + type JournalBlockPointer } from './journal-row-table' import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure' import { pendingJournalRepairSequence } from './journal-repair-marker' @@ -26,6 +27,8 @@ const FIRST_JOURNAL_SEQUENCE = 1 export type JournalLoad = { state: JournalReducerState + /** The block the live epoch's rows are keyed under. */ + block: number /** 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. */ @@ -40,10 +43,11 @@ export type JournalLoad = { /** 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) { + const pointer = readJournalSessionPointer(db, sessionId) + if (!pointer) { return null } + const { epoch } = pointer const state = createJournalReducerState(sessionId, epoch) const repairedFrom = pendingJournalRepairSequence(db, sessionId, epoch) let expectedSequence = FIRST_JOURNAL_SEQUENCE @@ -56,7 +60,7 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal let latched = false let truncateFrom: number | undefined - for (const entry of iterateJournalEpochRows(db, sessionId, epoch)) { + for (const entry of iterateJournalEpochRows(db, pointer)) { const parsed = parseJournalRow(entry.rowJson) if (!parsed.ok) { truncateFrom = entry.seq @@ -96,6 +100,7 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal state.oldestSequence = FIRST_JOURNAL_SEQUENCE return { state, + block: pointer.block, readOnly: latched, corrupt: gapSequence !== undefined || @@ -112,13 +117,12 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal * cannot parse, exactly as replay does. */ export function readJournalRowsAfterCursor( db: Database.Database, - sessionId: string, - epoch: string, + pointer: JournalBlockPointer, afterSequence: number, limit?: number ): JournalRow[] { const rows: JournalRow[] = [] - for (const stored of readJournalRowsAfter(db, sessionId, epoch, afterSequence, limit)) { + for (const stored of readJournalRowsAfter(db, pointer, afterSequence, limit)) { const parsed = parseJournalRow(stored.rowJson) if (!parsed.ok) { break 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 index ec5cce3c1f52..3af5afe4a4c2 100644 --- 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 @@ -15,11 +15,12 @@ import Database from '../../sqlite/sync-database' import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + readTestJournalRows } from './journal-host-database-test-support' import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' import { importPerSessionJournal } from './journal-per-session-import' -import { readJournalEpochRows, type JournalStoredRow } from './journal-row-table' +import type { JournalStoredRow } from './journal-row-table' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-legacy', @@ -62,7 +63,7 @@ async function historyRows(): Promise<{ epoch: string; rows: JournalStoredRow[] { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'On it.' }] }, { fence: 1 } ) - const rows = readJournalEpochRows( + const rows = readTestJournalRows( openTestJournalHostDatabase(scratch).db, IDENTITY.sessionId, journal.epoch @@ -123,7 +124,7 @@ describe('importing a per-chat journal', () => { expect(journal.epoch).toBe(epoch) expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) expect( - readJournalEpochRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) + readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) ).toEqual(rows) expect(existsSync(legacyDir())).toBe(false) expect(existsSync(`${legacyDir()}.imported`)).toBe(true) 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 index 2d1e5b6032cb..44055ef76161 100644 --- 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 @@ -15,7 +15,14 @@ import Database from '../../sqlite/sync-database' import type { SqliteRow } from '../../sqlite/sqlite-statement' import type { JournalHostDatabase } from './journal-host-database' import { legacyJournalDatabaseFile } from './journal-paths' -import { publishJournalSessionEpoch, readJournalSessionEpoch } from './journal-row-table' +import { + allocateJournalBlock, + deleteJournalBlock, + journalRowId, + publishJournalSessionEpoch, + readJournalSessionEpoch, + readJournalSessionPointer +} from './journal-row-table' /** The newest per-chat file shape any build wrote. */ const LEGACY_JOURNAL_SCHEMA_VERSION = 2 @@ -28,9 +35,7 @@ const HAS_LEGACY_REPAIRS = "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'journal_repairs'" const SELECT_LEGACY_REPAIR = 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?' -const INSERT_ROW = - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' -const DELETE_SESSION_ROWS = 'DELETE FROM journal_rows WHERE session_id = ?' +const INSERT_ROW = 'INSERT INTO journal_rows (id, 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 @@ -106,7 +111,11 @@ function copyLegacyJournal( } const repair = readLegacyRepair(source, sessionId) input.database.transaction((db) => { - db.prepare(DELETE_SESSION_ROWS).run(sessionId) + const retired = readJournalSessionPointer(db, sessionId) + const block = allocateJournalBlock(db) + if (retired) { + deleteJournalBlock(db, retired.block) + } const insert = db.prepare(INSERT_ROW) let afterSeq = Number.MIN_SAFE_INTEGER for (;;) { @@ -115,7 +124,7 @@ function copyLegacyJournal( .all(sessionId, epoch, afterSeq, LEGACY_ROW_PAGE_SIZE) for (const row of page) { // Copied as stored: the bytes are the row, its epoch and sequence included. - insert.run(sessionId, epoch, row.seq, row.ts, row.row_json) + insert.run(journalRowId(block, Number(row.seq)), row.ts, row.row_json) } const last = page.at(-1)?.seq if (page.length < LEGACY_ROW_PAGE_SIZE || typeof last !== 'number') { @@ -123,7 +132,7 @@ function copyLegacyJournal( } afterSeq = last } - publishJournalSessionEpoch(db, input.identity, epoch) + publishJournalSessionEpoch(db, input.identity, { epoch, block }) if (repair) { db.prepare(UPSERT_REPAIR).run( sessionId, 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 747d273f5b15..8fcf71bce923 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 @@ -15,7 +15,7 @@ import type Database from '../../sqlite/sync-database' import type { JournalHostDatabase } from './journal-host-database' -import { deleteJournalRowSuffix } from './journal-row-table' +import { deleteJournalRowSuffix, type JournalBlockPointer } from './journal-row-table' const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?' const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) @@ -50,7 +50,7 @@ export function clearJournalRepairMarker(db: Database.Database, sessionId: strin export function deleteJournalRepairedSuffix(input: { database: JournalHostDatabase sessionId: string - epoch: string + pointer: JournalBlockPointer /** First sequence of the rejected suffix. */ fromSeq: number /** First sequence left free once the suffix is gone. */ @@ -58,8 +58,13 @@ export function deleteJournalRepairedSuffix(input: { now: number }): number { const deleted = input.database.transaction((db) => { - const count = deleteJournalRowSuffix(db, input.sessionId, input.epoch, input.fromSeq) - db.prepare(UPSERT_REPAIR).run(input.sessionId, input.epoch, input.contentFrom, input.now) + const count = deleteJournalRowSuffix(db, input.pointer.block, input.fromSeq) + db.prepare(UPSERT_REPAIR).run( + input.sessionId, + input.pointer.epoch, + input.contentFrom, + input.now + ) return count }) void input.database.reclaimFreePages() 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 c95ecb9e9857..6657ec509def 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 @@ -14,7 +14,9 @@ import type { import { parseJournalRow } from './journal-row-schema' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + liveTestJournalRows, + updateTestJournalRowJson } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -114,16 +116,10 @@ describe('context facts on replayed turn rows', () => { await journal.close() 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.close() } 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 b3bd8fa88c24..833128f3a6f5 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 @@ -5,7 +5,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + liveTestJournalRows } from './journal-host-database-test-support' // Which rows an older host can still read: only rows that carry a turn item @@ -50,10 +51,8 @@ describe('journal row schema versions', () => { await journal.close() 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], 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 a6d812a31b4e..0cf588f3afec 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,57 +1,106 @@ // Every statement the journal issues against `journal_rows` / `journal_sessions`. // -// Each one is a prefix or range scan of one chat's rows on the primary key. Columns are always -// named: `SELECT *` is uncacheable and can drop a column. +// A chat's live epoch owns one block of row ids, `[block * 2^32, (block + 1) * 2^32)`, and a row's +// id is `block * 2^32 + seq`. So one chat's rows sit together on their own leaf pages, a replay is +// one range scan, and replacing a chat's history deletes one contiguous range. Measured on the +// largest real chat beside 19 others: 39 ms and 7.5 MB of WAL, against 214 ms and 102 MB for a +// `(session_id, epoch, seq)` key. Ids are computed in Number arithmetic, never bitwise: blocks +// stay below 2^21, so every id stays below 2^53. Columns are always named. 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 = ?' +/** Where a chat's live epoch is stored: its UUID, and the block its rows are keyed under. */ +export type JournalBlockPointer = { epoch: string; block: number } + +const BLOCK_SPAN = 2 ** 32 +/** Refused at and above: `(2^21) * 2^32` is 2^53, the first id Number cannot hold exactly. */ +export const JOURNAL_BLOCK_LIMIT = 2 ** 21 +export const JOURNAL_SEQUENCE_LIMIT = BLOCK_SPAN + +const SELECT_POINTER = 'SELECT epoch, block FROM journal_sessions WHERE session_id = ?' +const SELECT_NEXT_BLOCK = 'SELECT coalesce(max(block), -1) + 1 AS next FROM journal_sessions' // A new epoch invalidates the saved status, which was computed at a position of the old one. -const PUBLISH_SESSION_EPOCH = `INSERT INTO journal_sessions (session_id, workspace_id, epoch) -VALUES (?, ?, ?) +const PUBLISH_SESSION_EPOCH = `INSERT INTO journal_sessions (session_id, workspace_id, epoch, block) +VALUES (?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET - workspace_id = excluded.workspace_id, epoch = excluded.epoch, + workspace_id = excluded.workspace_id, epoch = excluded.epoch, block = excluded.block, status_json = NULL, status_seq = NULL` -const INSERT_ROW = - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' -const SELECT_ROWS_AFTER = `SELECT epoch, seq, ts, row_json FROM journal_rows -WHERE session_id = ? AND epoch = ? AND seq > ? ORDER BY seq ASC` +const INSERT_ROW = 'INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)' +const SELECT_ROWS_AFTER = `SELECT id, ts, row_json FROM journal_rows +WHERE id > ? AND id < ? ORDER BY id ASC` const SELECT_ROWS_AFTER_LIMITED = `${SELECT_ROWS_AFTER} LIMIT ?` -const DELETE_SESSION_ROWS = 'DELETE FROM journal_rows WHERE session_id = ?' -const DELETE_SUFFIX = 'DELETE FROM journal_rows WHERE session_id = ? AND epoch = ? AND seq >= ?' -const SELECT_TIP = 'SELECT max(seq) AS tip FROM journal_rows WHERE session_id = ? AND epoch = ?' +const DELETE_RANGE = 'DELETE FROM journal_rows WHERE id >= ? AND id < ?' +const SELECT_TIP = 'SELECT max(id) AS tip FROM journal_rows WHERE id >= ? AND id < ?' + +export class JournalKeySpaceError extends Error { + constructor(message: string) { + super(message) + this.name = 'JournalKeySpaceError' + } +} + +export function journalRowId(block: number, seq: number): number { + if (!Number.isInteger(seq) || seq < 1 || seq >= JOURNAL_SEQUENCE_LIMIT) { + throw new JournalKeySpaceError(`sequence ${seq} is outside a journal block`) + } + return block * BLOCK_SPAN + seq +} + +function blockStart(block: number): number { + return block * BLOCK_SPAN +} + +export function readJournalSessionPointer( + db: Database.Database, + sessionId: string +): JournalBlockPointer | null { + const row = db.prepare(SELECT_POINTER).get(sessionId) + return typeof row?.epoch === 'string' && typeof row.block === 'number' + ? { epoch: row.epoch, block: row.block } + : null +} 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 + return readJournalSessionPointer(db, sessionId)?.epoch ?? null } -/** Points the chat at `epoch`. Only an epoch change writes this row; an append never does. */ +/** A block no chat's live epoch holds. Inside the transaction that publishes it. */ +export function allocateJournalBlock(db: Database.Database): number { + const next = db.prepare(SELECT_NEXT_BLOCK).get()?.next + const block = typeof next === 'number' ? next : 0 + if (block >= JOURNAL_BLOCK_LIMIT) { + throw new JournalKeySpaceError('the chat journal has no block left to allocate') + } + return block +} + +/** Points the chat at `epoch` in `block`. Only an epoch change writes this row. */ export function publishJournalSessionEpoch( db: Database.Database, identity: { sessionId: string; workspaceId: string }, - epoch: string + pointer: JournalBlockPointer ): void { - db.prepare(PUBLISH_SESSION_EPOCH).run(identity.sessionId, identity.workspaceId, epoch) + db.prepare(PUBLISH_SESSION_EPOCH).run( + identity.sessionId, + identity.workspaceId, + pointer.epoch, + pointer.block + ) } -export function insertJournalRow( - db: Database.Database, - sessionId: string, - row: JournalRow -): number { +export function insertJournalRow(db: Database.Database, block: number, row: JournalRow): number { const rowJson = serializeJournalRow(row) - db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson) + db.prepare(INSERT_ROW).run(journalRowId(block, row.seq), row.ts, rowJson) return Buffer.byteLength(rowJson, 'utf8') } -/** The live epoch's highest sequence, or 0 when it holds no row. */ -export function readJournalTip(db: Database.Database, sessionId: string, epoch: string): number { - const tip = db.prepare(SELECT_TIP).get(sessionId, epoch)?.tip - return typeof tip === 'number' ? tip : 0 +/** The block's highest sequence, or 0 when it holds no row. */ +export function readJournalTip(db: Database.Database, block: number): number { + const tip = db.prepare(SELECT_TIP).get(blockStart(block), blockStart(block + 1))?.tip + return typeof tip === 'number' ? tip - blockStart(block) : 0 } // Why pages, not `.iterate()`: a lazily consumed cursor pins a read snapshot for as long as the @@ -59,15 +108,14 @@ export function readJournalTip(db: Database.Database, sessionId: string, epoch: // 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 live epoch's rows in sequence order, fetched one completed statement at a time. */ export function* iterateJournalEpochRows( db: Database.Database, - sessionId: string, - epoch: string + pointer: JournalBlockPointer ): Generator { - let afterSeq = Number.MIN_SAFE_INTEGER + let afterSeq = 0 for (;;) { - const page = readJournalRowsAfter(db, sessionId, epoch, afterSeq, EPOCH_ROW_PAGE_SIZE) + const page = readJournalRowsAfter(db, pointer, afterSeq, EPOCH_ROW_PAGE_SIZE) yield* page const last = page.at(-1) if (page.length < EPOCH_ROW_PAGE_SIZE || last === undefined) { @@ -77,49 +125,45 @@ export function* iterateJournalEpochRows( } } -/** Every row of one epoch, in sequence order. */ -export function readJournalEpochRows( - db: Database.Database, - sessionId: string, - epoch: string -): JournalStoredRow[] { - return [...iterateJournalEpochRows(db, sessionId, epoch)] -} - export function readJournalRowsAfter( db: Database.Database, - sessionId: string, - epoch: string, + pointer: JournalBlockPointer, 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 from = blockStart(pointer.block) + Math.max(afterSeq, 0) + const to = blockStart(pointer.block + 1) + const rows = + limit !== undefined + ? db.prepare(SELECT_ROWS_AFTER_LIMITED).all(from, to, limit) + : db.prepare(SELECT_ROWS_AFTER).all(from, to) + return rows.flatMap((row) => + typeof row.id === 'number' && typeof row.ts === 'number' && typeof row.row_json === 'string' + ? [ + { + epoch: pointer.epoch, + seq: row.id - blockStart(pointer.block), + ts: row.ts, + rowJson: row.row_json + } + ] + : [] + ) } -/** Every row the chat holds, whatever its epoch: a new epoch replaces them all. */ -export function deleteJournalSessionRows(db: Database.Database, sessionId: string): void { - db.prepare(DELETE_SESSION_ROWS).run(sessionId) +/** Every row of a retired block, in the transaction that retires it. */ +export function deleteJournalBlock(db: Database.Database, block: number): void { + db.prepare(DELETE_RANGE).run(blockStart(block), blockStart(block + 1)) } /** Drop the rejected suffix a repair found, from `fromSeq` to the tip. */ export function deleteJournalRowSuffix( db: Database.Database, - sessionId: string, - epoch: string, + block: number, fromSeq: number ): number { - const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq) + const deleted = db + .prepare(DELETE_RANGE) + .run(journalRowId(block, Math.max(fromSeq, 1)), blockStart(block + 1)) 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 42c58b4b3534..bc25757e3c2d 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 @@ -10,14 +10,15 @@ 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 type { JournalHostDatabase } from './journal-host-database' -import { - insertJournalRow, - readJournalEpochRows, - publishJournalSessionEpoch -} from './journal-row-table' import type { JournalRow } from './journal-row-schema' import { JournalRowWriter } from './journal-row-writer' -import { openTestJournalHostDatabase } from './journal-host-database-test-support' +import { readJournalSessionPointer } from './journal-row-table' +import { + openTestJournalHostDatabase, + readTestJournalRows, + insertTestJournalRow, + publishTestJournalEpoch +} from './journal-host-database-test-support' const SESSION_ID = 'session-1' const EPOCH = 'epoch-1' @@ -44,7 +45,7 @@ describe('journal row writer', () => { beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-')) database = openTestJournalHostDatabase(root) - publishJournalSessionEpoch(database.db, { sessionId: SESSION_ID, workspaceId: 'ws-1' }, EPOCH) + publishTestJournalEpoch(database.db, SESSION_ID, EPOCH) readOnly = false }) @@ -65,6 +66,7 @@ describe('journal row writer', () => { now: () => 1, serialize: (run) => run(), database: () => database, + block: () => readJournalSessionPointer(database.db, SESSION_ID)?.block ?? -1, readOnly: () => readOnly, highestFence: () => 0, nextSequence: () => sequence, @@ -79,13 +81,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 ac920059cd09..d9c219749c6b 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 @@ -8,6 +8,7 @@ export type JournalRowWriterDeps = { now: () => number serialize: (run: () => Promise) => Promise database: () => JournalHostDatabase + block: () => number readOnly: () => boolean highestFence: () => number nextSequence: () => number @@ -23,7 +24,7 @@ export class JournalRowWriter { const row = build(this.deps.nextSequence(), this.deps.now()) assertJournalFence(row.fence, this.deps.highestFence()) // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction((db) => insertJournalRow(db, this.deps.sessionId, row)) + this.deps.database().transaction((db) => insertJournalRow(db, this.deps.block(), row)) // COMMIT landed, so the row is durable: adopt it before anything that can // fail. Rejecting here instead would leave the next append reusing a // sequence the table already holds. diff --git a/src/main/native-chat/agent-session-journal/journal-session-status.test.ts b/src/main/native-chat/agent-session-journal/journal-session-status.test.ts index 49d5d561f705..8a77fae2eea3 100644 --- a/src/main/native-chat/agent-session-journal/journal-session-status.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-session-status.test.ts @@ -37,8 +37,9 @@ function trustedStatuses(db: Database.Database): { session_id: string; status_js return db .prepare( `SELECT s.session_id, s.status_json FROM journal_sessions s - WHERE s.status_json IS NOT NULL AND s.status_seq = ( - SELECT max(r.seq) FROM journal_rows r WHERE r.session_id = s.session_id AND r.epoch = s.epoch)` + WHERE s.status_json IS NOT NULL AND s.block * 4294967296 + s.status_seq = ( + SELECT max(r.id) FROM journal_rows r + WHERE r.id >= s.block * 4294967296 AND r.id < (s.block + 1) * 4294967296)` ) .all() as { session_id: string; status_json: string }[] } diff --git a/src/main/native-chat/agent-session-journal/journal-session-status.ts b/src/main/native-chat/agent-session-journal/journal-session-status.ts index bedcf210b14b..dbae152285bc 100644 --- a/src/main/native-chat/agent-session-journal/journal-session-status.ts +++ b/src/main/native-chat/agent-session-journal/journal-session-status.ts @@ -3,7 +3,14 @@ // every epoch change clears the column (journal-row-table.ts), so no old position survives one. import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' +import type { StructuredAgentSessionStatusProjection } from '../../../shared/structured-agent-session-projection' +import { + STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + type StructuredAgentSessionSavedStatus +} from '../../../shared/structured-agent-session-saved-status' import type Database from '../../sqlite/sync-database' +import type { JournalHostDatabase } from './journal-host-database' +import { AgentSessionJournalError } from './journal-write-guards' const WRITE_STATUS = `UPDATE journal_sessions SET status_json = ?, status_seq = ? WHERE session_id = ? AND epoch = ?` @@ -16,3 +23,55 @@ export function writeJournalSessionStatus( ): void { db.prepare(WRITE_STATUS).run(statusJson, cursor.sequence, sessionId, cursor.epoch) } + +/** + * One chat's status column. Written only when the epoch or the projection changes, so about once + * per turn end, and through the chat's own queue so it lands after the rows it describes. + * Bookkeeping: a failed write costs a later boot a miss, never a user action. + */ +export class JournalListingStatusWriter { + /** The epoch and projection last saved or in flight. */ + private saved: string | null = null + + constructor( + private readonly deps: { + sessionId: string + database: () => JournalHostDatabase + serialize: (run: () => Promise) => Promise + } + ) {} + + save( + cursor: AgentJournalCursor, + projection: StructuredAgentSessionStatusProjection, + lastActivityAt: number + ): void { + const key = `${cursor.epoch}\n${JSON.stringify(projection)}` + if (key === this.saved) { + return + } + const status: StructuredAgentSessionSavedStatus = { + v: STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, + projection, + lastActivityAt + } + this.saved = key + this.deps + .serialize(async () => + writeJournalSessionStatus( + this.deps.database().db, + this.deps.sessionId, + cursor, + JSON.stringify(status) + ) + ) + .catch((error: unknown) => { + if (this.saved === key) { + this.saved = null + } + if (!(error instanceof AgentSessionJournalError && error.code === 'journal_closed')) { + console.warn('[agent-session-journal] saving the listing status failed', error) + } + }) + } +} 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 6cd44eebbe1e..9b1fbe5b473c 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 @@ -27,6 +27,7 @@ export type JournalStoreHost = { serialize: (run: () => Promise) => Promise database: () => JournalHostDatabase state: () => JournalReducerState + block: () => number readOnly: () => boolean setReadOnly: (readOnly: boolean) => void cursor: () => AgentJournalCursor @@ -71,6 +72,7 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal now: host.now, serialize: host.serialize, database: host.database, + block: host.block, readOnly: host.readOnly, highestFence: () => host.state().highestFence, nextSequence: () => host.state().lastSequence + 1, 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 a7163860be96..9d9c3624eff3 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 @@ -18,6 +18,7 @@ type JournalDisclosure = JournalRepairDisclosure export function journalStoreLoadedFields(loaded: JournalLoad) { return { state: loaded.state, + block: loaded.block, readOnly: loaded.readOnly, malformedRows: loaded.malformedRows } 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 f93e09f6b974..cae8e5035a8e 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 @@ -34,7 +34,7 @@ export function restoreJournalStore( deleteJournalRepairedSuffix({ database: host.database(), sessionId: host.identity.sessionId, - epoch: host.state().epoch, + pointer: { epoch: host.state().epoch, block: host.block() }, fromSeq, contentFrom, now: host.now() 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 216352387fd1..027328e98bf4 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 @@ -21,7 +21,9 @@ import { journalDatabasePath } from './journal-host-database' import type { AgentSessionJournal } from './journal-store' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + liveTestJournalRows, + insertTestJournalRowJson } from './journal-host-database-test-support' const IDENTITY: AgentSessionJournalIdentity = { @@ -70,9 +72,8 @@ async function withDatabase(run: (db: Database.Database) => void): Promise /** 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) }) } @@ -163,10 +164,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.ts b/src/main/native-chat/agent-session-journal/journal-store.test.ts index d8fdfbcf0c46..627b48d8b3c1 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 @@ -23,7 +23,9 @@ import { AgentSessionJournalError, type AgentSessionJournal } from './journal-st import type { openAgentSessionJournal } from './journal-store-factory' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + liveTestJournalRows, + deleteTestJournalRow } from './journal-host-database-test-support' import type Database from '../../sqlite/sync-database' @@ -249,7 +251,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() @@ -258,8 +260,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 }) }) @@ -393,8 +395,7 @@ describe('on-disk layout', () => { 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 }) 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 40f302a07d8d..047c7f05549e 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -19,10 +19,6 @@ import { currentAgentSessionThreadGoalBySequence } from '../../../shared/agent-s import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage' import type { StructuredAgentSessionStatusProjection } from '../../../shared/structured-agent-session-projection' -import { - STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, - type StructuredAgentSessionSavedStatus -} from '../../../shared/structured-agent-session-saved-status' import { activeStructuredAgentSessionTurnIdBySequence, newestStructuredAgentSessionTurnBySequence @@ -66,8 +62,7 @@ import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' -import { writeJournalSessionStatus } from './journal-session-status' -import { AgentSessionJournalError } from './journal-write-guards' +import { JournalListingStatusWriter } from './journal-session-status' export { AgentSessionJournalError } from './journal-write-guards' @@ -78,13 +73,14 @@ export class AgentSessionJournal { private readonly mintEpoch: () => string private state: JournalReducerState + /** The block the live epoch's rows are keyed under; moves with the epoch. */ + private block = -1 private readOnly = false private malformedRows = 0 private openedCorrupt = false private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } private onCommitted: (() => void) | null = null - /** The epoch and projection last saved, so an unchanged status is not written per row. */ - private savedListingStatus: string | null = null + private readonly listingStatus: JournalListingStatusWriter private readonly queue: JournalWriteQueue private readonly rowWriter: JournalRowWriter private readonly epochController: JournalEpochController @@ -100,6 +96,11 @@ export class AgentSessionJournal { this.state = createJournalReducerState(options.identity.sessionId, '') // Serializes sequence assignment with the durable write behind it. this.queue = new JournalWriteQueue(options.identity.sessionId) + this.listingStatus = new JournalListingStatusWriter({ + sessionId: options.identity.sessionId, + database: () => this.database, + serialize: (run) => this.queue.serialize(run) + }) const collaborators = createJournalStoreCollaborators({ identity: this.identity, legacyDirectory: this.database.legacyDirectoryFor(this.identity), @@ -108,6 +109,7 @@ export class AgentSessionJournal { serialize: (run) => this.queue.serialize(run), database: () => this.database, state: () => this.state, + block: () => this.block, readOnly: () => this.readOnly, setReadOnly: (readOnly) => { this.readOnly = readOnly @@ -184,40 +186,11 @@ export class AgentSessionJournal { this.onCommitted = listener } - /** - * Saves the settled listing status at the fold's position, after the rows it describes have - * committed. Written only when the epoch or the projection changes, so about once per turn end. - * Bookkeeping: a failed write costs a later boot a miss, never a user action. - */ + /** Saves the settled listing status at the fold's position, after the rows it describes. */ saveListingStatus(projection: StructuredAgentSessionStatusProjection): void { - const cursor = this.cursor() - const key = `${cursor.epoch}\n${JSON.stringify(projection)}` - if (this.readOnly || key === this.savedListingStatus) { - return - } - const saved: StructuredAgentSessionSavedStatus = { - v: STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, - projection, - lastActivityAt: this.state.lastActivityAt + if (!this.readOnly) { + this.listingStatus.save(this.cursor(), projection, this.state.lastActivityAt) } - this.savedListingStatus = key - this.queue - .serialize(async () => - writeJournalSessionStatus( - this.database.db, - this.identity.sessionId, - cursor, - JSON.stringify(saved) - ) - ) - .catch((error: unknown) => { - if (this.savedListingStatus === key) { - this.savedListingStatus = null - } - if (!(error instanceof AgentSessionJournalError && error.code === 'journal_closed')) { - console.warn('[agent-session-journal] saving the listing status failed', error) - } - }) } cursor = (): AgentJournalCursor => ({ @@ -279,8 +252,7 @@ export class AgentSessionJournal { rowsAfter: (afterSequence) => readJournalRowsAfterCursor( this.database.db, - this.identity.sessionId, - this.state.epoch, + { epoch: this.state.epoch, block: this.block }, afterSequence, limit ), 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 619517a13024..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 @@ -5,10 +5,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' import type { JournalHostDatabase } from './journal-host-database' import { replayJournal } from './journal-open' -import { insertJournalRow, publishJournalSessionEpoch } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import * as reducer from './journal-reducer' -import { openTestJournalHostDatabase } from './journal-host-database-test-support' +import { + openTestJournalHostDatabase, + insertTestJournalRow, + publishTestJournalEpoch, + insertTestJournalRowJson +} from './journal-host-database-test-support' let root: string let opened: JournalHostDatabase @@ -43,13 +47,13 @@ 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 = openTestJournalHostDatabase(root) - publishJournalSessionEpoch(opened.db, { sessionId: sessionId, workspaceId: 'ws-1' }, epoch) + publishTestJournalEpoch(opened.db, sessionId, epoch) }) afterEach(async () => { @@ -131,9 +135,7 @@ 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, '{') + insertTestJournalRowJson(opened.db, sessionId, 5, '{') const loaded = replayJournal(opened.db, sessionId)! expect(loaded).toMatchObject({ readOnly: false, 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 998f32b1b1a4..eee68eb4cd06 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,15 @@ import { type AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { - insertJournalRow, - publishJournalSessionEpoch -} 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, - openTestJournalHostDatabase + 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' @@ -49,8 +49,8 @@ async function seedJournal(count: number) { ts: 1_000 } db.exec('BEGIN IMMEDIATE') - publishJournalSessionEpoch(db, { sessionId: identity.sessionId, workspaceId: 'ws-1' }, base.epoch) - insertJournalRow(db, identity.sessionId, { + publishTestJournalEpoch(db, identity.sessionId, base.epoch) + insertTestJournalRow(db, identity.sessionId, { ...base, kind: 'epoch', seq: 1, @@ -58,7 +58,7 @@ async function seedJournal(count: number) { providerHandle: identity.providerHandle }) for (let index = 0; index < count; index += 1) { - insertJournalRow(db, identity.sessionId, { + insertTestJournalRow(db, identity.sessionId, { ...base, kind: 'item', seq: index + 2, @@ -81,7 +81,7 @@ function observeForwardReads() { const prepare = Database.prototype.prepare vi.spyOn(Database.prototype, 'prepare').mockImplementation(function (this: Database, sql) { const statement = prepare.call(this, sql) - if (sql.includes('seq > ?') && !observed.has(statement)) { + if (sql.includes('id > ?') && !observed.has(statement)) { observed.add(statement) const all = statement.all.bind(statement) vi.spyOn(statement, 'all').mockImplementation((...args) => { @@ -164,10 +164,7 @@ 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 } = openTestJournalHostDatabase(root!) - db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( - identity.sessionId, - 4 - ) + deleteTestJournalRow(db, identity.sessionId, 4) const first = readAgentSessionHistory(journal, { sessionId: identity.sessionId, direction: 'after', @@ -193,11 +190,7 @@ describe('forward history SQL read budget', () => { async (rowJson) => { const journal = await seedJournal(6) const { db } = openTestJournalHostDatabase(root!) - db.prepare('UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND seq = ?').run( - rowJson, - identity.sessionId, - 4 - ) + 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 5c8c42c10a37..de60219b1688 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 @@ -19,10 +19,10 @@ import { serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + insertTestJournalRow } from '../agent-session-journal/journal-host-database-test-support' import type { JournalItemRow, @@ -488,7 +488,7 @@ async function reopenWithRawRows(rows: readonly RawSeedRow[]): Promise { await withJournalDatabase(journalDir, (db) => { - db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run(CODEX_SESSION, seq) + deleteTestJournalRow(db, CODEX_SESSION, seq) }) } @@ -202,13 +205,10 @@ describe('openAgentSessionJournalWithRecovery', () => { 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 }) ) }) @@ -224,7 +224,7 @@ describe('openAgentSessionJournalWithRecovery', () => { expect(opened.recovery).toBeNull() expect(opened.journal.isReadOnly).toBe(true) 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) }) @@ -249,14 +249,10 @@ describe('openAgentSessionJournalWithRecovery', () => { const neighbourEpoch = other.epoch await other.close() const database = openTestJournalHostDatabase(journalDir) - const neighbourBefore = readJournalEpochRows(database.db, neighbour.sessionId, neighbourEpoch) + const neighbourBefore = readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch) await seedJournal(3) await withJournalDatabase(journalDir, (db) => { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND seq = ?').run( - '}{', - CODEX_SESSION, - 3 - ) + updateTestJournalRowJson(db, CODEX_SESSION, 3, '}{') }) const opened = await openAgentSessionJournalWithRecovery({ @@ -271,7 +267,7 @@ describe('openAgentSessionJournalWithRecovery', () => { expect(opened.recovery?.imported).toBeGreaterThan(0) const reopened = await journals.open({ identity: neighbour, stateDirectory: journalDir }) expect(reopened.epoch).toBe(neighbourEpoch) - expect(readJournalEpochRows(database.db, neighbour.sessionId, neighbourEpoch)).toEqual( + expect(readTestJournalRows(database.db, neighbour.sessionId, neighbourEpoch)).toEqual( neighbourBefore ) }) @@ -421,7 +417,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 }) }) }) @@ -450,7 +446,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, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts index fcced1db4174..cfae688b0730 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-subscribers.test.ts @@ -11,11 +11,11 @@ import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES, serializeRemoteRuntimePayload } from '../../../shared/remote-runtime-memory-limits' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + insertTestJournalRow } from '../agent-session-journal/journal-host-database-test-support' import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' import { MAX_RETAINED_SESSION_ACTIVITIES } from './structured-agent-session-activity-retention' @@ -424,7 +424,7 @@ describe('AgentSessionSubscribers', () => { try { opened.db.exec('BEGIN IMMEDIATE') for (const row of rows) { - insertJournalRow(opened.db, SESSION, row) + insertTestJournalRow(opened.db, SESSION, row) } opened.db.exec('COMMIT') } finally { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts index 530b665d81f5..5693384052e7 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-wire-admission.test.ts @@ -10,12 +10,12 @@ import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-w import { REMOTE_RUNTIME_MAX_OUTBOUND_JSON_BYTES } from '../../../shared/remote-runtime-memory-limits' import { mobileE2EETextPayloadAdmissionBytes } from '../../runtime/rpc/mobile-e2ee-outbound-admission' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { insertJournalRow } from '../agent-session-journal/journal-row-table' import type { JournalRow } from '../agent-session-journal/journal-row-schema' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { createTrackedJournalOpener, - openTestJournalHostDatabase + openTestJournalHostDatabase, + insertTestJournalRow } from '../agent-session-journal/journal-host-database-test-support' import { readAgentSessionHistory } from './agent-session-history-page' import { AgentSessionSubscribers } from './structured-agent-session-subscribers' @@ -172,7 +172,7 @@ async function reopenWithOversizedRemoval(afterSequence: number): Promise { await foundRestTestChat(rig) await rig.host.flushAllStreamedEvents() // A row that no longer parses: the recovering open keeps the readable prefix and rebuilds. - openTestJournalHostDatabase(rig.root) - .db.prepare('UPDATE journal_rows SET row_json = ? WHERE session_id = ? AND seq = ?') - .run('}{', SESSION, 2) + updateTestJournalRowJson(openTestJournalHostDatabase(rig.root).db, SESSION, 2, '}{') await rig.restart() setStructuredAgentSessionHost(rig.host) From 1e1a809d16ec448c9a936eaa19b84d38e76355e4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:27:29 -0700 Subject: [PATCH 03/66] feat(native-chat): copy a chat's per-chat journal again when an older Orca wrote it after a downgrade A per-chat journal.db that reappears after its chat was copied in is the newer history: an older build, run after a downgrade, attached the chat and wrote it. - journal_imports records the (epoch, tip) each chat was copied from, in the copy's own transaction. A file already copied is never copied again, across any number of restarts after a failed rename; a file that differs always is. - Newest writer wins, per chat, with a row saying the chat was continued in an older version of Orca. When both builds wrote past the recorded tip under one epoch, the copy takes a fresh epoch, so readers reset instead of skipping rows. - Each copied directory retires to its own .imported-- name, so a second downgrade and re-upgrade never collides with the first. --- .../journal-database-schema.ts | 10 +- .../journal-per-session-import.test.ts | 135 ++++++++++++-- .../journal-per-session-import.ts | 173 ++++++++++++------ .../journal-per-session-reimport.ts | 117 ++++++++++++ 4 files changed, 359 insertions(+), 76 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts 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 5747b6400171..6e78a738127c 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 @@ -4,8 +4,9 @@ // `journal_sessions` names each chat's live epoch and the block its rows sit in, 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). `status_json` is the chat's last settled listing status, -// trusted only where `status_seq` is still the tip of the live epoch. +// behind (see journal-repair-marker.ts). `journal_imports` records which per-chat file each chat +// was last copied from (journal-per-session-reimport.ts). `status_json` is the chat's last settled +// listing status, trusted only where `status_seq` is still the tip of the live epoch. /** 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 latches this build @@ -33,5 +34,10 @@ 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 +); ` } 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 index 3af5afe4a4c2..bba60e41c3de 100644 --- 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 @@ -1,7 +1,9 @@ // A chat's per-chat journal file from an earlier build is copied into the host's one database on -// that chat's first open: verbatim, once, and retired only after the copy commits. +// that chat's open: verbatim, and retired only after the copy commits. A file that reappears after +// a downgrade is the newer history, and is copied again. -import { existsSync } from 'node:fs' +import type * as NodeFs from 'node:fs' +import { existsSync, renameSync } from 'node:fs' import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -20,8 +22,14 @@ import { } from './journal-host-database-test-support' import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' import { importPerSessionJournal } from './journal-per-session-import' +import { JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY } from './journal-per-session-reimport' import type { JournalStoredRow } from './journal-row-table' +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, renameSync: vi.fn(actual.renameSync) } +}) + const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-legacy', workspaceId: 'ws-1', @@ -43,13 +51,16 @@ function legacyDir(): string { } /** Real rows, written by today's store into a scratch database, as an earlier build wrote them. */ -async function historyRows(): Promise<{ epoch: string; rows: JournalStoredRow[] }> { - const scratch = join(root, 'scratch') +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-from-the-earlier-build' + mintEpoch: () => epoch }) await journal.appendSubmission({ clientMessageId: 'client-1', @@ -60,7 +71,7 @@ async function historyRows(): Promise<{ epoch: string; rows: JournalStoredRow[] }) await journal.appendItem( item(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'On it.' }] }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: reply }] }, { fence: 1 } ) const rows = readTestJournalRows( @@ -99,6 +110,28 @@ CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, } } +/** Every directory an import retired this chat's files to. */ +async function retired(): Promise { + const parent = dirname(legacyDir()) + const prefix = `${legacyDir().slice(parent.length + 1)}.imported-` + return (await readdir(parent)).filter((name) => name.startsWith(prefix)) +} + +function texts(journal: { snapshot: () => { items: { body: unknown }[] } }): string { + return JSON.stringify(journal.snapshot().items.map((entry) => entry.body)) +} + +function renameFails(): void { + vi.mocked(renameSync).mockImplementation(() => { + throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) + }) +} + +async function renameWorks(): Promise { + const actual = await vi.importActual('node:fs') + vi.mocked(renameSync).mockImplementation(actual.renameSync) +} + function openChat() { return journals.open({ identity: IDENTITY, stateDirectory: root, now: () => (clock += 1) }) } @@ -110,6 +143,7 @@ beforeEach(async () => { afterEach(async () => { vi.restoreAllMocks() + await renameWorks() await journals.closeAll() await rm(root, { recursive: true, force: true }) }) @@ -127,7 +161,7 @@ describe('importing a per-chat journal', () => { readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) ).toEqual(rows) expect(existsSync(legacyDir())).toBe(false) - expect(existsSync(`${legacyDir()}.imported`)).toBe(true) + expect(await retired()).toEqual([expect.stringMatching(/\.imported-epoch-fr-\d+$/)]) }) // T-B3: the upgrade restart is the restart that produced the offers. A new epoch or renumbered @@ -152,12 +186,11 @@ describe('importing a per-chat journal', () => { }) // T-R2B1: the copy committed and only the rename failed. Rows appended since, a restart, and a - // reopen with the file still there: nothing is imported again, and nothing is lost. - it('never imports again after a failed rename, across a restart', async () => { + // reopen with the file still there: nothing is copied again, and nothing is lost. + it('never copies the same file again after a failed rename, across a restart', async () => { const { epoch, rows } = await historyRows() await writeLegacyJournal(epoch, rows) - // A non-empty directory where the retired one goes: every rename fails, as a held handle would. - await mkdir(join(`${legacyDir()}.imported`, 'blocker'), { recursive: true }) + renameFails() vi.spyOn(console, 'warn').mockImplementation(() => undefined) const journal = await openChat() @@ -172,13 +205,8 @@ describe('importing a per-chat journal', () => { const reopened = await openChat() expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 }) - expect(JSON.stringify(reopened.snapshot().items)).toContain('after the upgrade') - - // The next open retries only the rename, which now succeeds. - await rm(`${legacyDir()}.imported`, { recursive: true }) - await journals.closeAll() - await openChat() - expect(existsSync(legacyDir())).toBe(false) + expect(texts(reopened)).toContain('after the upgrade') + expect(texts(reopened)).not.toContain(JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY.clientMessageId) }) // T-import-transient: a read that fails leaves the file for the next open, which imports it. @@ -231,4 +259,75 @@ describe('importing a per-chat journal', () => { await openChat() expect(existsSync(legacyDir())).toBe(false) }) + + // T-B5: a downgrade, an older build writing the chat's history to a new per-chat file, and a + // re-upgrade — twice. The newer history wins each time, says so, and each file retires to its + // own name. + it('copies the newer history an older build wrote, on every re-upgrade', async () => { + const first = await historyRows() + 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 } + ) + await journals.closeAll() + + for (const [cycle, epoch] of ['epoch-downgrade-one', 'epoch-downgrade-two'].entries()) { + const older = await historyRows(epoch, `older build, cycle ${cycle + 1}`) + await writeLegacyJournal(older.epoch, older.rows) + await journals.closeAll() + + const reopened = await openChat() + expect(reopened.epoch).toBe(epoch) + expect(texts(reopened)).toContain(`older build, cycle ${cycle + 1}`) + expect(texts(reopened)).not.toContain('this build') + expect(texts(reopened)).toContain('continued in an older version of Orca') + expect(existsSync(legacyDir())).toBe(false) + await journals.closeAll() + } + expect(await retired()).toHaveLength(3) + }) + + // N-R3.1: the rename failed, this build appended, and an older build then appended to that same + // file. Both advanced from the recorded tip under one epoch, so the copy takes a fresh one: a + // reader at this build's tip resets instead of silently skipping the older build's rows. + it('gives the copy a fresh epoch when both builds wrote past the same recorded tip', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + renameFails() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const journal = await openChat() + await journal.appendItem( + item(2), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'this build' }] }, + { fence: 1 } + ) + await journals.closeAll() + const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq: rows.length + 1 } + 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, rows.length + 1, 1, JSON.stringify(olderRow)) + legacy.close() + await renameWorks() + + const reopened = await openChat() + + expect(reopened.epoch).not.toBe(epoch) + expect(texts(reopened)).toContain('older') + expect(texts(reopened)).toContain('continued in an older version of Orca') + expect(reopened.readSince({ epoch, sequence: rows.length + 1 })).toEqual({ + ok: false, + reset: 'epoch_changed' + }) + expect(existsSync(legacyDir())).toBe(false) + }) }) 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 index 44055ef76161..2a85fbe00c73 100644 --- 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 @@ -1,13 +1,15 @@ -// Copying a chat's per-chat journal file into the host's one database, on that chat's first open. +// 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. +// same row after it. A file that reappears after a downgrade is copied again (see +// journal-per-session-reimport.ts). // -// The rename that retires the source runs only after the copy commits. 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. +// The rename that retires the source runs only after the copy commits, to a name no earlier +// retirement used. 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 { existsSync, renameSync } from 'node:fs' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -15,12 +17,18 @@ import Database from '../../sqlite/sync-database' import type { SqliteRow } from '../../sqlite/sqlite-statement' import type { JournalHostDatabase } from './journal-host-database' import { legacyJournalDatabaseFile } from './journal-paths' +import { + planPerSessionImport, + reimportedJournalRows, + writePerSessionImportMarker, + type PerSessionImportPlan, + type PerSessionJournalHead +} from './journal-per-session-reimport' import { allocateJournalBlock, deleteJournalBlock, journalRowId, publishJournalSessionEpoch, - readJournalSessionEpoch, readJournalSessionPointer } from './journal-row-table' @@ -29,6 +37,8 @@ const LEGACY_JOURNAL_SCHEMA_VERSION = 2 const LEGACY_ROW_PAGE_SIZE = 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_REPAIRS = @@ -44,45 +54,58 @@ ON CONFLICT(session_id) DO UPDATE SET export type PerSessionJournalImportDeps = { openSource?: (path: string) => Database.Database rename?: (from: string, to: string) => void + now?: () => number } export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' -/** The name a copied directory is moved to, so nothing reads it again. */ -export function importedLegacyJournalDirectory(legacyDirectory: string): string { - return `${legacyDirectory}.imported` +type ImportInput = { + database: JournalHostDatabase + identity: AgentSessionJournalIdentity + legacyDirectory: string +} & PerSessionJournalImportDeps + +/** Where a copied directory goes: a name no earlier retirement of the same chat used. */ +export function importedLegacyJournalDirectory( + legacyDirectory: string, + epoch: string, + now: number +): string { + return `${legacyDirectory}.imported-${epoch.slice(0, 8)}-${now}` } -export function importPerSessionJournal( - input: { - database: JournalHostDatabase - identity: AgentSessionJournalIdentity - legacyDirectory: string - } & PerSessionJournalImportDeps -): PerSessionJournalImportOutcome { +export function importPerSessionJournal(input: ImportInput): PerSessionJournalImportOutcome { const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) if (!existsSync(sourcePath)) { return 'absent' } - // Import once: a chat that already has a journal here was copied, and only its rename failed. - if (readJournalSessionEpoch(input.database.db, input.identity.sessionId) !== null) { - retireImportedDirectory(input) - return 'already-imported' - } + const { sessionId } = input.identity + const current = readJournalSessionPointer(input.database.db, sessionId) const source = (input.openSource ?? openLegacySource)(sourcePath) - let copied: boolean + let legacy: PerSessionJournalHead | null + let plan: PerSessionImportPlan | null = null try { - copied = copyLegacyJournal(input, source) + legacy = readLegacyHead(source, sessionId) + if (legacy) { + plan = planPerSessionImport({ db: input.database.db, sessionId, legacy, current }) + if (plan.kind !== 'copied') { + copyLegacyJournal(input, source, legacy, plan) + } + } } finally { source.close() } - if (!copied) { - // Never written. Left in place for this open, whose empty chat may still owe the notice about - // a pre-SQLite transcript beside it; the next open finds the chat founded and retires it. - return 'absent' + 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 (!current) { + return 'absent' + } + retireImportedDirectory(input, 'unwritten') + return 'already-imported' } - retireImportedDirectory(input) - return 'imported' + retireImportedDirectory(input, legacy.epoch) + return plan?.kind === 'copied' ? 'already-imported' : 'imported' } /** A plain read-only connection: it sees committed WAL frames without checkpointing them. */ @@ -100,16 +123,60 @@ function openLegacySource(path: string): Database.Database { } } -function copyLegacyJournal( - input: { database: JournalHostDatabase; identity: AgentSessionJournalIdentity }, - source: Database.Database -): boolean { - const { sessionId } = input.identity +function readLegacyHead( + source: Database.Database, + sessionId: string +): PerSessionJournalHead | null { const epoch = source.prepare(SELECT_LEGACY_EPOCH).get(sessionId)?.epoch if (typeof epoch !== 'string' || epoch.length === 0) { - return false + return null } + const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip + return { epoch, tip: typeof tip === 'number' ? tip : 0 } +} + +function* legacyRows( + source: Database.Database, + sessionId: string, + epoch: string +): Generator<{ seq: number; ts: number; rowJson: string }> { + let afterSeq = Number.MIN_SAFE_INTEGER + for (;;) { + const page = source + .prepare(SELECT_LEGACY_ROWS) + .all(sessionId, epoch, afterSeq, LEGACY_ROW_PAGE_SIZE) + for (const row of page) { + yield { seq: Number(row.seq), ts: Number(row.ts), rowJson: String(row.row_json) } + } + const last = page.at(-1)?.seq + if (page.length < LEGACY_ROW_PAGE_SIZE || typeof last !== 'number') { + return + } + afterSeq = last + } +} + +/** One transaction: the rows, the chat's pointer, its repair marker and the import marker. */ +function copyLegacyJournal( + input: ImportInput, + source: Database.Database, + legacy: PerSessionJournalHead, + plan: Exclude +): void { + const { sessionId } = input.identity + const epoch = plan.kind === 'again' ? plan.epoch : legacy.epoch const repair = readLegacyRepair(source, sessionId) + // A second copy is read whole: it is rewritten under a fresh epoch or gains a disclosure row. + const rows = + plan.kind === 'again' + ? reimportedJournalRows({ + sessionId, + legacyEpoch: legacy.epoch, + epoch, + rows: [...legacyRows(source, sessionId, legacy.epoch)], + now: (input.now ?? Date.now)() + }) + : legacyRows(source, sessionId, legacy.epoch) input.database.transaction((db) => { const retired = readJournalSessionPointer(db, sessionId) const block = allocateJournalBlock(db) @@ -117,32 +184,25 @@ function copyLegacyJournal( deleteJournalBlock(db, retired.block) } const insert = db.prepare(INSERT_ROW) - let afterSeq = Number.MIN_SAFE_INTEGER - for (;;) { - const page = source - .prepare(SELECT_LEGACY_ROWS) - .all(sessionId, epoch, afterSeq, LEGACY_ROW_PAGE_SIZE) - for (const row of page) { - // Copied as stored: the bytes are the row, its epoch and sequence included. - insert.run(journalRowId(block, Number(row.seq)), row.ts, row.row_json) - } - const last = page.at(-1)?.seq - if (page.length < LEGACY_ROW_PAGE_SIZE || typeof last !== 'number') { - break - } - afterSeq = last + for (const row of rows) { + // Copied as stored: the bytes are the row, its epoch and sequence included. + insert.run(journalRowId(block, row.seq), row.ts, row.rowJson) } publishJournalSessionEpoch(db, input.identity, { epoch, block }) if (repair) { db.prepare(UPSERT_REPAIR).run( sessionId, - repair.epoch, + repair.epoch === legacy.epoch ? epoch : repair.epoch, repair.content_from, repair.repaired_at ) } + writePerSessionImportMarker(db, sessionId, legacy) }) - return true + if (plan.kind === 'again') { + // The block this copy replaced. + void input.database.reclaimFreePages() + } } /** The per-chat repair marker, as stored; v1 files predate the table. */ @@ -154,11 +214,12 @@ function readLegacyRepair(source: Database.Database, sessionId: string): SqliteR } /** Best effort: the copy is committed, so a failed rename only leaves a file the next open skips. */ -function retireImportedDirectory(input: { - legacyDirectory: string - rename?: (from: string, to: string) => void -}): void { - const target = importedLegacyJournalDirectory(input.legacyDirectory) +function retireImportedDirectory(input: ImportInput, epoch: string): void { + const target = importedLegacyJournalDirectory( + input.legacyDirectory, + epoch, + (input.now ?? Date.now)() + ) try { ;(input.rename ?? renameSync)(input.legacyDirectory, target) } catch (error) { 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..d8a45dc584b7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-reimport.ts @@ -0,0 +1,117 @@ +// 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. That file is the newer history. +// +// `journal_imports` records which file each chat was copied from — its epoch and tip — in the +// same transaction as the copy, so a file already copied (only its rename failed, across any +// number of restarts) is never copied again, and a file that differs always is. Newest writer +// wins, per chat, and the chat says so. When both builds advanced one epoch from the recorded +// tip, the copy takes a fresh epoch, so every reader resets instead of silently skipping rows. + +import { randomUUID } from 'node:crypto' +import type Database from '../../sqlite/sync-database' +import { boundJournalStatusText } from './journal-prompt-body-bounds' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { buildJournalItemRow } from './journal-row-builders' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' +import { readJournalTip, type JournalBlockPointer } from './journal-row-table' + +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 const JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY = { + provider: 'orca', + clientMessageId: 'journal-continued-in-older-orca' +} as const + +const OLDER_BUILD_DISCLOSURE = + 'This chat was continued in an older version of Orca. Its history now comes from that version; anything this version recorded before then was replaced.' + +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 +} + +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' } + /** The file is newer history: copied again, as `epoch`, with a row saying so. */ + | { kind: 'again'; epoch: string } + +/** 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 + current: JournalBlockPointer | null +}): PerSessionImportPlan { + const marker = readPerSessionImportMarker(input.db, input.sessionId) + if (marker?.epoch === input.legacy.epoch && marker.tip === input.legacy.tip) { + return { kind: 'copied' } + } + if (!marker && !input.current) { + return { kind: 'first' } + } + // Both sides wrote past the recorded tip under one epoch: replacing it in place would leave a + // reader at this build's tip skipping the older build's rows. + const bothAdvanced = + marker !== null && + input.current?.epoch === marker.epoch && + input.legacy.epoch === marker.epoch && + input.legacy.tip > marker.tip && + readJournalTip(input.db, input.current.block) > marker.tip + return { kind: 'again', epoch: bothAdvanced ? randomUUID() : input.legacy.epoch } +} + +/** + * The rows a second copy writes: the file's rows under `epoch` — byte-identical when the epoch is + * the file's own — then the row that says the chat continued in an older Orca. + */ +export function reimportedJournalRows(input: { + sessionId: string + legacyEpoch: string + epoch: string + rows: readonly { seq: number; ts: number; rowJson: string }[] + now: number +}): { seq: number; ts: number; rowJson: string }[] { + const state = createJournalReducerState(input.sessionId, input.epoch) + const copied = input.rows.map((stored) => { + const parsed = parseJournalRow(stored.rowJson) + if (!parsed.ok) { + throw new Error(`per-chat journal row ${stored.seq} of ${input.sessionId} is unreadable`) + } + const row: JournalRow = + input.epoch === input.legacyEpoch ? parsed.row : { ...parsed.row, epoch: input.epoch } + applyJournalRow(state, row) + return input.epoch === input.legacyEpoch + ? stored + : { seq: stored.seq, ts: stored.ts, rowJson: serializeJournalRow(row) } + }) + const disclosure = buildJournalItemRow({ + state, + identity: JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY, + body: { kind: 'status', text: boundJournalStatusText(OLDER_BUILD_DISCLOSURE) }, + seq: state.lastSequence + 1, + fence: state.highestFence, + ts: input.now + }) + return [ + ...copied, + { seq: disclosure.seq, ts: disclosure.ts, rowJson: serializeJournalRow(disclosure) } + ] +} From 0f76d9c4982d3649c691de9a9c42a03dd6c696a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:39:13 -0700 Subject: [PATCH 04/66] test(native-chat): fixture deps match the host journal database shape Attach-flow and reconcile-attach fixtures stop passing a journal database those inputs do not take, and host and restore fixtures pass the one they now require instead of the removed journal root. --- ...-agent-session-acquisition-options.test.ts | 10 ---------- ...tured-agent-session-adopted-import.test.ts | 1 - ...gent-session-attach-reconciliation.test.ts | 3 --- ...d-agent-session-host-runtime-state.test.ts | 20 ++++++++++++++++--- ...nt-session-processless-reservation.test.ts | 2 -- ...ed-agent-session-readable-restorer.test.ts | 18 +++++++++++++++-- ...ured-agent-session-restart-restore.test.ts | 18 +++++++++++++++-- 7 files changed, 49 insertions(+), 23 deletions(-) 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 f3984bbaf968..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 @@ -155,7 +155,6 @@ describe('structured session acquisition options', () => { const first = await performAttach({ store: initialStore, adapter: withHistory('created'), - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', @@ -196,7 +195,6 @@ describe('structured session acquisition options', () => { const second = await performAttach({ store, adapter: withHistory('resumed'), - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-b', @@ -231,7 +229,6 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: sessionAdapter, - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', @@ -264,7 +261,6 @@ describe('structured session acquisition options', () => { performAttach({ store, adapter: sessionAdapter, - journalDatabase: openTestJournalHostDatabase(root!), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken, @@ -296,7 +292,6 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: adapter({ origin: 'created' }), - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', @@ -336,7 +331,6 @@ describe('structured session acquisition options', () => { models: [] } }), - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-b', @@ -376,7 +370,6 @@ describe('structured session acquisition options', () => { const created = await performAttach({ store, adapter: sessionAdapter, - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', @@ -416,7 +409,6 @@ describe('structured session acquisition options', () => { performAttach({ store, adapter: failingAdapter, - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', @@ -511,7 +503,6 @@ describe('structured session acquisition options', () => { performAttach({ store: target, adapter: failingAdapter, - journalDatabase: openTestJournalHostDatabase(root!), openConversation: openTestAttachConversation( openTestJournalHostDatabase(root!), failingAdapter @@ -616,7 +607,6 @@ describe('the tab a create reserves', () => { return performAttach({ store, adapter: adapter({ origin: 'created' }), - journalDatabase: openTestJournalHostDatabase(root!), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', 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 b3c2c635777d..5344a671cce6 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 @@ -122,7 +122,6 @@ async function attach( return performAttach({ store, adapter: sessionAdapter, - journalDatabase: openTestJournalHostDatabase(root!), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', 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 3a863e95ce96..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 @@ -85,7 +85,6 @@ async function attach(adapter: StructuredAgentSessionAdapter) { const attached = await attachJournal({ record: RECORD, params: PARAMS, - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root)), adapter }) @@ -181,8 +180,6 @@ describe('attachJournal restart reconciliation', () => { const attached = await attachJournal({ record: RECORD, params: PARAMS, - journalDatabase: openTestJournalHostDatabase(root), - adapter, openConversation: async () => journal }) 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..209109479959 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, @@ -51,7 +65,7 @@ function runtimeState( const deps = { store: { getRecord: () => record } as unknown as AgentSessionRecordStore, adapter: {}, - journalRoot: '/tmp', + journalDatabase: openTestJournalHostDatabase(stateDirectory), claimKeyId: 'key-1', probeOwner } as StructuredAgentSessionHostDeps @@ -125,7 +139,7 @@ describe('host runtime-state owner probe', () => { 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-processless-reservation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts index fa3a9b74e552..204ffba30da6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts @@ -87,7 +87,6 @@ describe('processless structured session reservation', () => { performAttach({ store, adapter, - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', @@ -228,7 +227,6 @@ describe('processless structured session reservation', () => { performAttach({ store, adapter, - journalDatabase: openTestJournalHostDatabase(root), openConversation: openTestAttachConversation(openTestJournalHostDatabase(root!)), authority: { spawnToken: 'spawn-a', diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts index d1cc9408a62e..5eaedfffb384 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts @@ -1,5 +1,12 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' const { restoreOnRestart } = vi.hoisted(() => ({ restoreOnRestart: vi.fn() })) @@ -9,6 +16,13 @@ vi.mock('./structured-agent-session-restart-restore', () => ({ import { StructuredAgentSessionReadableRestorer } from './structured-agent-session-readable-restorer' +// The restore pool is mocked; the host database only fills the deps' shape. +const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-readable-restorer-')) +afterAll(() => { + closeTestJournalHostDatabases() + rmSync(stateDirectory, { recursive: true, force: true }) +}) + describe('StructuredAgentSessionReadableRestorer', () => { beforeEach(() => { restoreOnRestart.mockReset().mockResolvedValue(undefined) @@ -21,7 +35,7 @@ describe('StructuredAgentSessionReadableRestorer', () => { const restorer = new StructuredAgentSessionReadableRestorer({ openDeps: { store: { getRecord: () => null, listRecords: () => records }, - journalRoot: '/tmp/journals', + journalDatabase: openTestJournalHostDatabase(stateDirectory), adapter: {} }, supportsRecord: () => true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 90875832c8f2..1804413b789b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -1,6 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionAttachParams } from './structured-agent-session-attach' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' const { restoreRead } = vi.hoisted(() => ({ restoreRead: vi.fn() @@ -12,9 +19,16 @@ vi.mock('./structured-agent-session-read-restore', () => ({ import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' +// The restore under test is mocked; the host database only fills the deps' shape. +const stateDirectory = mkdtempSync(join(tmpdir(), 'orca-restart-restore-')) +afterAll(() => { + closeTestJournalHostDatabases() + rmSync(stateDirectory, { recursive: true, force: true }) +}) + const NO_OPEN_DEPS = { store: { getRecord: () => null, listRecords: () => [] }, - journalRoot: '/tmp/journals', + journalDatabase: openTestJournalHostDatabase(stateDirectory), adapter: {} } From c368fd3d19900c397919356c39d96c8d11737bcc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 03:40:56 -0700 Subject: [PATCH 05/66] test(native-chat): state why the runtime-state fixtures' existing casts are safe --- .../structured-agent-session-host-runtime-state.test.ts | 2 ++ 1 file changed, 2 insertions(+) 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 209109479959..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 @@ -62,6 +62,7 @@ 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: {}, @@ -133,6 +134,7 @@ 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], From d31a34109797f84f28d9848ea37d28d873785a48 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:00:02 -0700 Subject: [PATCH 06/66] fix(native-chat): start up normally when this process cannot open the chat journal A process refused the chat journal, because another Orca owns it or because its own journal will not open, failed startup restoration: the window booted in degraded no-save mode and a paired phone could not list any tabs. Startup restoration now treats the refusal structured requests are getting as having no structured host; terminals, tabs and saving go on, structured requests are still refused by the gate, and the install is retried on the next one. Any other install error fails startup as before. --- ...structured-agent-session-create-support.ts | 11 ++- ...ed-agent-session-journal-ownership.test.ts | 75 +++++++++++++++++++ ...uctured-agent-session-journal-ownership.ts | 12 +++ 3 files changed, 97 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index ce2cf31b9ced..5fe664ad0364 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -19,6 +19,7 @@ import { } from './structured-agent-account-home' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' +import { isStructuredAgentSessionHostRefusal } from './structured-agent-session-journal-ownership' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -258,7 +259,15 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR return } // Durable agent records must exist before daemon inventory can be reconciled against them. - await this.ensureStructuredAgentSessionHost() + try { + await this.ensureStructuredAgentSessionHost() + } catch (error) { + // A refused host is no host: startup goes on, and only structured requests are refused. + if (!isStructuredAgentSessionHostRefusal(error)) { + throw error + } + console.warn('[structured-agent-session] startup continues without structured chats', error) + } await this.refreshMobileSessionPtyRecords() await getStructuredAgentSessionHost()?.reconcileRestartLeases() } diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index e81fbd7ea189..17cabe161968 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -205,6 +205,81 @@ describe('the owner, when its journal will not open', () => { }) }) +// A refused host is a no-host state for startup: the app restores terminals and tabs as usual, +// and only structured requests are refused. +describe('startup restoration without a structured host', () => { + function startupRuntime(ensureHost: () => Promise = install) { + const runtime = new OrcaRuntimeService() + const refreshPtyRecords = vi.fn(async () => new Set()) + const hydrateTabs = vi.fn(() => new Set()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = ensureHost + internal.refreshMobileSessionPtyRecords = refreshPtyRecords + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrateTabs + return { runtime, refreshPtyRecords, hydrateTabs } + } + + async function expectStartupWithoutHost(runtime: OrcaRuntimeService): Promise { + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + // What `session.tabs.list` awaits before it answers a paired client. + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + expect(getStructuredAgentSessionHost()).toBeNull() + } + + it('goes ahead while another process owns the chats', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords, hydrateTabs } = startupRuntime() + + await expectStartupWithoutHost(runtime) + + expect(refreshPtyRecords).toHaveBeenCalledOnce() + expect(hydrateTabs).toHaveBeenCalledWith('workspace-1', { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + expect(gateRefusal().reason).toBe('journalUnavailable') + }) + + it('goes ahead when the owner cannot open its journal', async () => { + await writeFile(journalDatabasePath(root), 'not a database '.repeat(512)) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords, hydrateTabs } = startupRuntime() + + await expectStartupWithoutHost(runtime) + + expect(refreshPtyRecords).toHaveBeenCalledOnce() + expect(hydrateTabs).toHaveBeenCalledWith('workspace-1', { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + }) + + it('still fails on an install error that refuses nothing', async () => { + const { runtime, refreshPtyRecords } = startupRuntime(async () => { + throw new Error('the record store would not open') + }) + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).rejects.toThrow( + 'the record store would not open' + ) + expect(refreshPtyRecords).not.toHaveBeenCalled() + }) +}) + describe('releasing ownership', () => { it('lets another process take the chats only after a clean stop', async () => { await install() diff --git a/src/main/runtime/structured-agent-session-journal-ownership.ts b/src/main/runtime/structured-agent-session-journal-ownership.ts index 612e5c87c5f2..e85100184408 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.ts @@ -9,6 +9,7 @@ import { resolve } from 'node:path' import { agentSessionRefusalError, + isAgentSessionRefusalError, type AgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { @@ -94,6 +95,17 @@ export function structuredAgentSessionHostRefusal(): AgentSessionRefusalError | return structuredAgentSessionJournalOwnerRefusal() ?? installRefusal } +/** Whether `error` is the refusal structured requests are getting right now. */ +export function isStructuredAgentSessionHostRefusal(error: unknown): boolean { + const refusal = structuredAgentSessionHostRefusal() + return ( + refusal !== null && + isAgentSessionRefusalError(error) && + error.refusal.code === refusal.refusal.code && + error.refusal.message === refusal.refusal.message + ) +} + /** Set when the owner could not open its journal database; cleared by a later install. The * install retries on the next call, so a refusal that can clear does. */ export function recordStructuredAgentSessionHostInstallRefusal( From 57faadbde61a8f78d20cb7e7743fd6c1fed28605 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:00:03 -0700 Subject: [PATCH 07/66] test(native-chat): name the owner-lock sweep test after the two sweeps it runs --- .../agent-session-journal/journal-owner-lock.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts index df5705c79b05..715508a85a17 100644 --- a/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts @@ -79,8 +79,8 @@ describe('the owner lock', () => { }) // T-owner-copy (N-R1): a POSIX close of ANY descriptor on the lock's inode drops the whole - // process's lock. Nothing that walks or copies userData in the owning process may open it. - it('survives every walk of the state directory the owning process makes', async () => { + // process's lock. These two run in the owner's state directory; neither may drop it. + it('stays held through the runtime socket sweep and the profile-state recovery copy', async () => { expect(acquire()).not.toBeNull() await writeFile(join(root, 'o-999999-stale.sock'), '') await writeFile(join(root, 'orca-data.json'), '{}') From 4f02a2da7a1fcbbe3fcad19ebe94c04b6569296b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:07:15 -0700 Subject: [PATCH 08/66] fix(native-chat): session history and terminal resume work while chats are refused Session history (listing and preparing a resume) and a terminal typing a resume command only check whether a structured chat owns a provider session. In a process refused the chat journal they failed outright. They now take the refusal chats are getting as having no structured host, the same treatment startup restoration gets, through one shared helper; any other install failure still fails them. Chat requests keep the gate's refusal. --- .../ai-vault/structured-session-ownership.ts | 4 +- .../register-core-handlers.ts | 7 ++- ...structured-agent-session-create-support.ts | 15 ++--- src/main/runtime/rpc/methods/ai-vault.test.ts | 61 +++++++++++++++++++ src/main/runtime/rpc/methods/ai-vault.ts | 9 ++- ...ed-agent-session-journal-ownership.test.ts | 21 ++++++- ...uctured-agent-session-journal-ownership.ts | 14 +++++ 7 files changed, 116 insertions(+), 15 deletions(-) diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index 03c84f023c60..f8e21f191f77 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-journal-ownership' 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/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index e692cc9c5e89..f957ce37680a 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -93,6 +93,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-journal-ownership' import type { PluginService } from '../../plugins/plugin-service' import type { PluginMarketplaceHandlerServices } from '../plugin-marketplaces' @@ -225,7 +226,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/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 5fe664ad0364..41ebef8f8a27 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -19,7 +19,7 @@ import { } from './structured-agent-account-home' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' -import { isStructuredAgentSessionHostRefusal } from './structured-agent-session-journal-ownership' +import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-journal-ownership' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -259,15 +259,10 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR return } // Durable agent records must exist before daemon inventory can be reconciled against them. - try { - await this.ensureStructuredAgentSessionHost() - } catch (error) { - // A refused host is no host: startup goes on, and only structured requests are refused. - if (!isStructuredAgentSessionHostRefusal(error)) { - throw error - } - console.warn('[structured-agent-session] startup continues without structured chats', error) - } + // A refused host is no host: startup goes on, and only structured requests are refused. + await ensureStructuredAgentSessionHostUnlessRefused(() => + this.ensureStructuredAgentSessionHost() + ) await this.refreshMobileSessionPtyRecords() await getStructuredAgentSessionHost()?.reconcileRestartLeases() } diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index d943775ef3ba..ab22ca4c90d1 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -25,6 +25,8 @@ import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams } from './ai-vault' +import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' +import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-journal-ownership' import { configureAiVaultSessionSources, listAiVaultSessions, @@ -221,6 +223,65 @@ describe('aiVault.prepareSessionResume', () => { }) }) +// Session history and terminal resume are not chats: a process whose chats are refused still +// serves them, while any other host failure still fails the request. +describe('aiVault methods without a structured host', () => { + const refusal = agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalCorrupt' }, + 'Unable to load this chat.' + ) + + beforeEach(() => { + resetAiVaultSessionListCacheForTests() + scanAiVaultSessionsInWorker.mockReset() + scanAiVaultSessionsInWorker.mockResolvedValue(makeResult()) + recordStructuredAgentSessionHostInstallRefusal(refusal) + }) + + afterEach(() => { + recordStructuredAgentSessionHostInstallRefusal(null) + resetAiVaultSessionListCacheForTests() + }) + + function refusedDispatcher(installError: Error): RpcDispatcher { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the two aiVault handlers read only these runtime members. + const runtime = { + getRuntimeId: () => 'test-runtime', + ensureStructuredAgentSessionHost: vi.fn().mockRejectedValue(installError), + listAiVaultSessions: (args?: Parameters[0]) => + listAiVaultSessions(args), + prepareAiVaultSessionResume: vi.fn().mockResolvedValue({ useRealCodexHome: true }) + } as unknown as OrcaRuntimeService + return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS }) + } + + it('lists and prepares a resume while chats are refused', async () => { + const dispatcher = refusedDispatcher(refusal) + + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + ).resolves.toMatchObject({ ok: true, result: makeResult() }) + await expect( + dispatcher.dispatch( + makeRequest('aiVault.prepareSessionResume', { + agent: 'codex', + filePath: '/managed/sessions/rollout-a.jsonl', + codexHome: '/managed' + }) + ) + ).resolves.toMatchObject({ ok: true, result: { useRealCodexHome: true } }) + }) + + it('still fails on a host error that refuses nothing', async () => { + const dispatcher = refusedDispatcher(new Error('the record store would not open')) + + await expect( + dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 })) + ).resolves.toMatchObject({ ok: false }) + }) +}) + describe('aiVault.listSessions handler + shared cache', () => { beforeEach(() => { resetAiVaultSessionListCacheForTests() diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index b6a2165939a0..4b2c11bab1e1 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -17,6 +17,7 @@ import { assertLegacyAiVaultResumeAllowed, projectStructuredAiVaultSessions } from '../../../ai-vault/structured-session-ownership' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-journal-ownership' import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, @@ -66,7 +67,9 @@ export const AI_VAULT_METHODS = [ name: 'aiVault.listSessions', params: AiVaultListSessionsParams, handler: async (params, { runtime, clientKind, clientCapabilities }) => { - await runtime.ensureStructuredAgentSessionHost() + await ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ) let result try { result = await runtime.listAiVaultSessions({ @@ -107,7 +110,9 @@ export const AI_VAULT_METHODS = [ // client-provided runtime/SSH stamp escape that host boundary. executionHostId: LOCAL_EXECUTION_HOST_ID } - await runtime.ensureStructuredAgentSessionHost() + await ensureStructuredAgentSessionHostUnlessRefused(() => + runtime.ensureStructuredAgentSessionHost() + ) assertLegacyAiVaultResumeAllowed(args) return runtime.prepareAiVaultSessionResume(args) } diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index 17cabe161968..a3fc8df5318c 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -21,6 +21,7 @@ import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire import Database from '../sqlite/sync-database' import { OrcaRuntimeService } from './orca-runtime' import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' +import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' import type { RpcContext } from './rpc/core' import { readRuntimeMetadata } from './runtime-metadata' import { OrcaRuntimeRpcServer } from './runtime-rpc' @@ -207,7 +208,7 @@ describe('the owner, when its journal will not open', () => { // A refused host is a no-host state for startup: the app restores terminals and tabs as usual, // and only structured requests are refused. -describe('startup restoration without a structured host', () => { +describe('startup and other non-chat work without a structured host', () => { function startupRuntime(ensureHost: () => Promise = install) { const runtime = new OrcaRuntimeService() const refreshPtyRecords = vi.fn(async () => new Set()) @@ -268,6 +269,24 @@ describe('startup restoration without a structured host', () => { }) }) + it('lets a terminal resume command through while another process owns the chats', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // The check `terminal.send` and `session.tabs.createTerminal` run before typing a resume. + await expect( + assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { + await install() + }) + ).resolves.toBeUndefined() + await expect( + assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { + throw new Error('the record store would not open') + }) + ).rejects.toThrow('the record store would not open') + expect(gateRefusal().reason).toBe('journalUnavailable') + }) + it('still fails on an install error that refuses nothing', async () => { const { runtime, refreshPtyRecords } = startupRuntime(async () => { throw new Error('the record store would not open') diff --git a/src/main/runtime/structured-agent-session-journal-ownership.ts b/src/main/runtime/structured-agent-session-journal-ownership.ts index e85100184408..c6430c6b2082 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.ts @@ -106,6 +106,20 @@ export function isStructuredAgentSessionHostRefusal(error: unknown): boolean { ) } +/** For work that goes on without chats: the refusal chats are getting leaves this process with no + * host, and any other install failure still throws. */ +export async function ensureStructuredAgentSessionHostUnlessRefused( + ensureHost: () => Promise +): Promise { + try { + await ensureHost() + } catch (error) { + if (!isStructuredAgentSessionHostRefusal(error)) { + throw error + } + } +} + /** Set when the owner could not open its journal database; cleared by a later install. The * install retries on the next call, so a refusal that can clear does. */ export function recordStructuredAgentSessionHostInstallRefusal( From a1b2dcfdceaad9cc27b855c6ab29b47edbb36022 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:07:16 -0700 Subject: [PATCH 09/66] test(native-chat): the first-work rename's fake journal saves the listing status --- src/main/agent-hooks/first-work-branch-rename.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 6332a4c23f65..0fa8c3489342 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -103,11 +103,13 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only these journal members. const journal = { snapshot: () => ({ items }), lastActivityAt: () => 1, isReadOnly: false, - cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) + cursor: () => ({ epoch: 1, sequence: (sequence += 1) }), + saveListingStatus: () => undefined } as unknown as AgentSessionJournal const pending: Promise[] = [] const observe = vi.fn((summary, options) => { @@ -190,10 +192,12 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only these journal members. const journal = { isReadOnly: false, lastActivityAt: () => 1, cursor: () => ({ epoch: 1, sequence: 1 }), + saveListingStatus: () => undefined, snapshot: () => ({ items: [ { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } }, From 1f2266a81c3b4c03fd60bcd797b2d188750fba2e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:07:18 -0700 Subject: [PATCH 10/66] fix(native-chat): open a chat whose per-chat journal file never got its schema A crash between creating a chat's journal.db and creating its tables left an empty or schema-less file. Each chat used to open that file as an empty chat; the importer instead refused the open as "try again" forever. A file with no journal_sessions table is now read as never written, the same as one with no rows. A file that is not a database, or whose read fails, is still refused. --- .../journal-per-session-import.test.ts | 25 +++++++++++++++++++ .../journal-per-session-import.ts | 9 ++++--- 2 files changed, 31 insertions(+), 3 deletions(-) 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 index bba60e41c3de..69815b24aca8 100644 --- 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 @@ -260,6 +260,31 @@ describe('importing a per-chat journal', () => { expect(existsSync(legacyDir())).toBe(false) }) + // 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 retires 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 (_shape, 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(existsSync(legacyDir())).toBe(false) + expect(await retired()).toHaveLength(1) + }) + // T-B5: a downgrade, an older build writing the chat's history to a new per-chat file, and a // re-upgrade — twice. The newer history wins each time, says so, and each file retires to its // own name. 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 index 2a85fbe00c73..f1a6be2727e8 100644 --- 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 @@ -41,8 +41,7 @@ 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_REPAIRS = - "SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'journal_repairs'" +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 = ?' const INSERT_ROW = 'INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)' @@ -127,6 +126,10 @@ 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 @@ -207,7 +210,7 @@ function copyLegacyJournal( /** The per-chat repair marker, as stored; v1 files predate the table. */ function readLegacyRepair(source: Database.Database, sessionId: string): SqliteRow | null { - if (!source.prepare(HAS_LEGACY_REPAIRS).get()) { + if (!source.prepare(HAS_LEGACY_TABLE).get('journal_repairs')) { return null } return source.prepare(SELECT_LEGACY_REPAIR).get(sessionId) ?? null From 3700f85f3a5e3cc43b1996b15cf88646afd061df Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:34:09 -0700 Subject: [PATCH 11/66] fix(native-chat): let the event loop run between chats during startup restore Opening a chat's journal is synchronous SQLite now that no per-chat directory is created first, so the restore of every visible chat ran as one main-thread task. Each chat now waits for a macrotask before it opens. --- ...ured-agent-session-restart-restore.test.ts | 38 +++++++++++++++++++ ...tructured-agent-session-restart-restore.ts | 9 +++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts index 1804413b789b..c7d04ea64cc4 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.test.ts @@ -78,6 +78,44 @@ describe('restart journal restoration', () => { expect(peak).toBe(4) }) + it('lets the event loop run between chats', async () => { + // Counts turns of the event loop while the restore runs; each open below is synchronous. + let turns = 0 + let ticking = true + const tick = (): void => { + turns += 1 + if (ticking) { + setImmediate(tick) + } + } + setImmediate(tick) + const turnsSeen = new Set() + restoreRead.mockImplementation(async () => { + turnsSeen.add(turns) + return null + }) + const records = Array.from( + { length: 12 }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restore reads only the record's session id here. + (_, index) => ({ sessionId: `session-${index}` }) as AgentSessionRecord + ) + + await restoreStructuredAgentSessionsOnRestart({ + openDeps: NO_OPEN_DEPS, + records, + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: () => false, + onReadable: () => undefined + }) + ticking = false + + expect(restoreRead).toHaveBeenCalledTimes(records.length) + // At most one chat per worker between two turns: never the whole restore in one task. + expect(turnsSeen.size).toBeGreaterThanOrEqual(records.length / 4) + }) + it('settles what a gone generation left running after recovery resolution, before publishing', async () => { const calls: string[] = [] const params: AgentSessionAttachParams = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts index d54687c25a64..4e4b7f1e5ddd 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-restore.ts @@ -11,6 +11,7 @@ // with no client attached and nothing on screen. A child now exists because work asked for it — a // send, through the delivery loop — not because a record survived on disk. +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { mapWithConcurrency } from '../../../shared/map-with-concurrency' @@ -72,7 +73,9 @@ async function restoreOneStructuredAgentSessionReadUnderSerialize( export async function restoreStructuredAgentSessionsOnRestart( input: StructuredAgentSessionReadRestoreDeps & { records: AgentSessionRecord[] } ): Promise { - await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, ({ sessionId }) => - restoreOneStructuredAgentSessionRead(input, sessionId) - ) + await mapWithConcurrency(input.records, JOURNAL_RESTORE_CONCURRENCY, async ({ sessionId }) => { + // A journal open is synchronous SQLite: without a macrotask per chat the restore is one long task. + await yieldToEventLoop() + await restoreOneStructuredAgentSessionRead(input, sessionId) + }) } From 93aed4a0d594b13dd884402b51e5adbb81f58694 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:34:10 -0700 Subject: [PATCH 12/66] fix(native-chat): import a per-chat journal in bounded batches The one-time copy of an earlier build's per-chat journal ran as one transaction, which blocked the main thread for 650 ms on the largest chat. Rows now copy 512 at a time, each batch its own transaction, yielding to the event loop between batches. The rows go into a block journal_import_blocks reserves, which no reader follows and no other chat is allocated; the last batch publishes the chat's pointer, repair marker and import marker together and releases the reservation. A copy that stops midway leaves only that block, which the next open clears and copies again. Two opens of one chat import one after the other. --- .../journal-database-schema.ts | 7 +- .../journal-per-session-import.test.ts | 102 +++++++++- .../journal-per-session-import.ts | 175 +++++++++++++----- .../journal-row-table.ts | 6 +- .../journal-store-restore.ts | 14 +- 5 files changed, 244 insertions(+), 60 deletions(-) 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 6e78a738127c..b5acf50c6ed9 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 @@ -5,7 +5,8 @@ // 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 last copied from (journal-per-session-reimport.ts). `status_json` is the chat's last settled +// was last copied from (journal-per-session-reimport.ts); `journal_import_blocks` the block a copy +// still in progress writes into, which no reader follows. `status_json` is the chat's last settled // listing status, trusted only where `status_seq` is still the tip of the live epoch. /** DB shape version, carried in `PRAGMA user_version`. Independent of the row body version @@ -39,5 +40,9 @@ CREATE TABLE IF NOT EXISTS journal_imports ( epoch TEXT NOT NULL, tip INTEGER NOT NULL ); +CREATE TABLE IF NOT EXISTS journal_import_blocks ( + session_id TEXT PRIMARY KEY, + block INTEGER NOT NULL UNIQUE +); ` } 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 index 69815b24aca8..1987fcbbfd3e 100644 --- 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 @@ -16,14 +16,16 @@ import type { AgentSessionResumeMarker } from '../../../shared/agent-session-res import Database from '../../sqlite/sync-database' import { createStructuredAgentSessionRestartOfferWithdrawal } from '../agent-session-wire/structured-agent-session-restart-offer-withdrawal' import { + closeTestJournalHostDatabases, createTrackedJournalOpener, + liveTestJournalRows, openTestJournalHostDatabase, readTestJournalRows } from './journal-host-database-test-support' import { journalDirectoryFor, legacyJournalDatabaseFile } from './journal-paths' import { importPerSessionJournal } from './journal-per-session-import' import { JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY } from './journal-per-session-reimport' -import type { JournalStoredRow } from './journal-row-table' +import { readJournalSessionPointer, type JournalStoredRow } from './journal-row-table' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -121,6 +123,10 @@ function texts(journal: { snapshot: () => { items: { body: unknown }[] } }): str 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 renameFails(): void { vi.mocked(renameSync).mockImplementation(() => { throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) @@ -185,6 +191,96 @@ describe('importing a per-chat journal', () => { 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: readJournalSessionPointer(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: the next open copies the chat again from the start, with no + // duplicate or leftover row, and no other chat is ever handed the block the copy reserved. + 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) + const reserved = reopened.db.prepare('SELECT block FROM journal_import_blocks').get()?.block + expect(rowCount(reopened.db)).toBe(1) + expect(readJournalSessionPointer(reopened.db, 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 } + ) + expect(readJournalSessionPointer(reopened.db, 'session-other')?.block).not.toBe(reserved) + 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 + ) + expect( + reopened.db.prepare('SELECT count(*) AS total FROM journal_import_blocks').get() + ).toEqual({ total: 0 }) + }) + + 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) + }) + // T-R2B1: the copy committed and only the rename failed. 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 rename, across a restart', async () => { @@ -216,7 +312,7 @@ describe('importing a per-chat journal', () => { const database = openTestJournalHostDatabase(root) const io = Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) - expect(() => + await expect( importPerSessionJournal({ database, identity: IDENTITY, @@ -225,7 +321,7 @@ describe('importing a per-chat journal', () => { throw io } }) - ).toThrow(io) + ).rejects.toThrow(io) expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) expect( database.db.prepare('SELECT count(*) AS total FROM journal_sessions').get() 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 index f1a6be2727e8..b16c5558c259 100644 --- 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 @@ -6,12 +6,19 @@ // same row after it. A file that reappears after a downgrade is copied 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 into a block `journal_import_blocks` reserves, which no reader follows: the chat's +// pointer, its repair and import markers land in the last batch, so the chat is imported all at once +// or not at all. A try that stops midway leaves only that reserved block, which the next try clears +// and copies again. +// // The rename that retires the source runs only after the copy commits, to a name no earlier // retirement used. 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 { existsSync, renameSync } from 'node:fs' +import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import Database from '../../sqlite/sync-database' import type { SqliteRow } from '../../sqlite/sqlite-statement' @@ -34,7 +41,8 @@ import { /** The newest per-chat file shape any build wrote. */ const LEGACY_JOURNAL_SCHEMA_VERSION = 2 -const LEGACY_ROW_PAGE_SIZE = 512 +/** Rows per batch: at most 31 ms per batch copying the largest real chat (68 MB, 3.3 KB rows). */ +const IMPORT_BATCH_ROWS = 512 const SELECT_LEGACY_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' const SELECT_LEGACY_TIP = @@ -45,6 +53,9 @@ const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 't const SELECT_LEGACY_REPAIR = 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?' const INSERT_ROW = 'INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)' +const SELECT_IMPORT_BLOCK = 'SELECT block FROM journal_import_blocks WHERE session_id = ?' +const RESERVE_IMPORT_BLOCK = 'INSERT INTO journal_import_blocks (session_id, block) VALUES (?, ?)' +const RELEASE_IMPORT_BLOCK = 'DELETE FROM journal_import_blocks WHERE session_id = ?' const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) VALUES (?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET @@ -54,6 +65,7 @@ export type PerSessionJournalImportDeps = { openSource?: (path: string) => Database.Database rename?: (from: string, to: string) => void now?: () => number + batchRows?: number } export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' @@ -73,7 +85,30 @@ export function importedLegacyJournalDirectory( return `${legacyDirectory}.imported-${epoch.slice(0, 8)}-${now}` } -export function importPerSessionJournal(input: ImportInput): PerSessionJournalImportOutcome { +/** 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' @@ -88,7 +123,7 @@ export function importPerSessionJournal(input: ImportInput): PerSessionJournalIm if (legacy) { plan = planPerSessionImport({ db: input.database.db, sessionId, legacy, current }) if (plan.kind !== 'copied') { - copyLegacyJournal(input, source, legacy, plan) + await copyLegacyJournal(input, source, legacy, plan) } } } finally { @@ -138,76 +173,122 @@ function readLegacyHead( return { epoch, tip: typeof tip === 'number' ? tip : 0 } } -function* legacyRows( +type ImportedRow = { seq: number; ts: number; rowJson: string } +type ImportBatch = { rows: ImportedRow[]; last: boolean } + +/** The file's rows, one bounded page per batch, read as each batch is written. */ +function* legacyRowBatches( source: Database.Database, sessionId: string, - epoch: string -): Generator<{ seq: number; ts: number; rowJson: string }> { + epoch: string, + batchRows: number +): Generator { + const select = source.prepare(SELECT_LEGACY_ROWS) let afterSeq = Number.MIN_SAFE_INTEGER for (;;) { - const page = source - .prepare(SELECT_LEGACY_ROWS) - .all(sessionId, epoch, afterSeq, LEGACY_ROW_PAGE_SIZE) - for (const row of page) { - yield { seq: Number(row.seq), ts: Number(row.ts), rowJson: String(row.row_json) } + 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 } - const last = page.at(-1)?.seq - if (page.length < LEGACY_ROW_PAGE_SIZE || typeof last !== 'number') { + afterSeq = lastSeq + } +} + +function* arrayBatches(rows: readonly ImportedRow[], batchRows: number): Generator { + for (let from = 0; ; from += batchRows) { + const last = from + batchRows >= rows.length + yield { rows: rows.slice(from, from + batchRows), last } + if (last) { return } - afterSeq = last } } -/** One transaction: the rows, the chat's pointer, its repair marker and the import marker. */ -function copyLegacyJournal( +/** + * Batches into a reserved block no reader follows; the last one publishes the chat's pointer, its + * repair marker and the import marker together. + */ +async function copyLegacyJournal( input: ImportInput, source: Database.Database, legacy: PerSessionJournalHead, plan: Exclude -): void { +): Promise { const { sessionId } = input.identity const epoch = plan.kind === 'again' ? plan.epoch : legacy.epoch const repair = readLegacyRepair(source, sessionId) + const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS // A second copy is read whole: it is rewritten under a fresh epoch or gains a disclosure row. - const rows = + const batches = plan.kind === 'again' - ? reimportedJournalRows({ - sessionId, - legacyEpoch: legacy.epoch, - epoch, - rows: [...legacyRows(source, sessionId, legacy.epoch)], - now: (input.now ?? Date.now)() - }) - : legacyRows(source, sessionId, legacy.epoch) - input.database.transaction((db) => { - const retired = readJournalSessionPointer(db, sessionId) - const block = allocateJournalBlock(db) - if (retired) { - deleteJournalBlock(db, retired.block) - } - const insert = db.prepare(INSERT_ROW) - for (const row of rows) { - // Copied as stored: the bytes are the row, its epoch and sequence included. - insert.run(journalRowId(block, row.seq), row.ts, row.rowJson) + ? arrayBatches( + reimportedJournalRows({ + sessionId, + legacyEpoch: legacy.epoch, + epoch, + rows: [...legacyRowBatches(source, sessionId, legacy.epoch, batchRows)].flatMap( + (batch) => batch.rows + ), + now: (input.now ?? Date.now)() + }), + batchRows + ) + : legacyRowBatches(source, sessionId, legacy.epoch, batchRows) + let block: number | null = null + for (const batch of batches) { + if (block !== null) { + await yieldToEventLoop() } - publishJournalSessionEpoch(db, input.identity, { epoch, block }) - if (repair) { - db.prepare(UPSERT_REPAIR).run( - sessionId, - repair.epoch === legacy.epoch ? epoch : repair.epoch, - repair.content_from, - repair.repaired_at - ) - } - writePerSessionImportMarker(db, sessionId, legacy) - }) + block = input.database.transaction((db) => { + const target = block ?? reserveImportBlock(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(journalRowId(target, row.seq), row.ts, row.rowJson) + } + if (batch.last) { + const retired = readJournalSessionPointer(db, sessionId) + if (retired) { + deleteJournalBlock(db, retired.block) + } + publishJournalSessionEpoch(db, input.identity, { epoch, block: target }) + db.prepare(RELEASE_IMPORT_BLOCK).run(sessionId) + if (repair) { + db.prepare(UPSERT_REPAIR).run( + sessionId, + repair.epoch === legacy.epoch ? epoch : repair.epoch, + repair.content_from, + repair.repaired_at + ) + } + writePerSessionImportMarker(db, sessionId, legacy) + } + return target + }) + } if (plan.kind === 'again') { // The block this copy replaced. void input.database.reclaimFreePages() } } +/** The block this chat's copy goes into. One an earlier try left behind is emptied and reused. */ +function reserveImportBlock(db: Database.Database, sessionId: string): number { + const staged = db.prepare(SELECT_IMPORT_BLOCK).get(sessionId)?.block + if (typeof staged === 'number') { + deleteJournalBlock(db, staged) + return staged + } + const block = allocateJournalBlock(db) + db.prepare(RESERVE_IMPORT_BLOCK).run(sessionId, block) + return block +} + /** The per-chat repair marker, as stored; v1 files predate the table. */ function readLegacyRepair(source: Database.Database, sessionId: string): SqliteRow | null { if (!source.prepare(HAS_LEGACY_TABLE).get('journal_repairs')) { 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 0cf588f3afec..8b614f46dc87 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-table.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-table.ts @@ -21,7 +21,9 @@ export const JOURNAL_BLOCK_LIMIT = 2 ** 21 export const JOURNAL_SEQUENCE_LIMIT = BLOCK_SPAN const SELECT_POINTER = 'SELECT epoch, block FROM journal_sessions WHERE session_id = ?' -const SELECT_NEXT_BLOCK = 'SELECT coalesce(max(block), -1) + 1 AS next FROM journal_sessions' +// Past every live block and every block an unfinished import reserved. +const SELECT_NEXT_BLOCK = `SELECT coalesce(max(block), -1) + 1 AS next FROM ( + SELECT block FROM journal_sessions UNION ALL SELECT block FROM journal_import_blocks)` // A new epoch invalidates the saved status, which was computed at a position of the old one. const PUBLISH_SESSION_EPOCH = `INSERT INTO journal_sessions (session_id, workspace_id, epoch, block) VALUES (?, ?, ?, ?) @@ -67,7 +69,7 @@ export function readJournalSessionEpoch(db: Database.Database, sessionId: string return readJournalSessionPointer(db, sessionId)?.epoch ?? null } -/** A block no chat's live epoch holds. Inside the transaction that publishes it. */ +/** A block no chat's live epoch or unfinished import holds. Inside the transaction that uses it. */ export function allocateJournalBlock(db: Database.Database): number { const next = db.prepare(SELECT_NEXT_BLOCK).get()?.next const block = typeof next === 'number' ? next : 0 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 cae8e5035a8e..7d802fad7b11 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 @@ -13,19 +13,19 @@ import { openJournalStoreState } from './journal-store-open' import { deleteJournalRepairedSuffix } from './journal-repair-marker' import { importPerSessionJournal } from './journal-per-session-import' -export function restoreJournalStore( +export async function restoreJournalStore( host: JournalStoreHost, collaborators: { epochController: JournalEpochController } ): Promise { + // A per-chat file left by an earlier build is this chat's newest history: copied in first. + await importPerSessionJournal({ + database: host.database(), + identity: host.identity, + legacyDirectory: host.legacyDirectory + }) return openJournalStoreState({ legacyDirectory: host.legacyDirectory, replay: () => { - // A per-chat file left by an earlier build is this chat's newest history: copied in first. - importPerSessionJournal({ - database: host.database(), - identity: host.identity, - legacyDirectory: host.legacyDirectory - }) const loaded = replayJournal(host.database().db, host.identity.sessionId) host.setOpenedCorrupt(loaded?.corrupt ?? false) return loaded From bfc41cf1ea4f61e52dd8b3e096ad57b5f9eb5d84 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:34:11 -0700 Subject: [PATCH 13/66] fix(native-chat): refuse chats when the owner lock file cannot be opened A lock file that is not a database, or cannot be opened, made the claim throw before any refusal was recorded, so startup restoration failed on every launch. The claim now sits in the same try as the database open and records the same typed refusal. --- ...ed-agent-session-journal-ownership.test.ts | 16 ++++++++++++++ .../structured-agent-session-runtime.ts | 21 +++++++++++-------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index a3fc8df5318c..8a7aedbcdc9c 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -11,6 +11,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { JOURNAL_OWNER_LOCK_FILE } from '../native-chat/agent-session-journal/journal-owner-lock' import { holdJournalOwnerLockInChild, probeJournalOwnerLockInChild, @@ -269,6 +270,21 @@ describe('startup and other non-chat work without a structured host', () => { }) }) + // Not a database, as a sync or restore tool can leave it: SQLite answers errcode 26 on its claim. + it('goes ahead when the owner lock file cannot be opened', async () => { + await writeFile(join(root, JOURNAL_OWNER_LOCK_FILE), 'not a database '.repeat(512)) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords } = startupRuntime() + + await expectStartupWithoutHost(runtime) + + expect(refreshPtyRecords).toHaveBeenCalledOnce() + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + }) + it('lets a terminal resume command through while another process owns the chats', async () => { holder = await holdJournalOwnerLockInChild(root) vi.spyOn(console, 'warn').mockImplementation(() => undefined) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index e6311f748888..cb9c627dc323 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -348,17 +348,16 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise Date: Mon, 28 Sep 2026 04:35:03 -0700 Subject: [PATCH 14/66] fix(native-chat): finish reclaiming pages a delete frees during a running pass A reclaim pass ended as soon as the freelist stopped shrinking between steps, so a second delete that freed more than one step's worth mid-pass ended it early and left those pages on the freelist. A pass now ends only when a step itself frees nothing, or the freelist is empty. --- .../journal-host-database.test.ts | 33 +++++++++++++++++++ .../journal-host-database.ts | 10 +++--- src/main/sqlite/sqlite-free-page-reclaim.ts | 6 +++- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index f50dbc5255fd..d1d2abd31996 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -18,6 +18,7 @@ import { openTestJournalHostDatabase, readTestJournalRows } from './journal-host-database-test-support' +import { deleteJournalBlock, journalRowId } from './journal-row-table' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -135,3 +136,35 @@ describe('quit', () => { ).toEqual([1, 2]) }) }) + +describe('handing freed pages back', () => { + // About one page per row; each block frees more than one reclaim step's worth. + function fillBlock( + db: ReturnType['db'], + block: number + ): void { + const insert = db.prepare('INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)') + db.exec('BEGIN') + for (let seq = 1; seq <= 3500; seq += 1) { + insert.run(journalRowId(block, seq), 1, 'x'.repeat(3500)) + } + db.exec('COMMIT') + } + + it('reclaims what a delete frees while a pass is already running', async () => { + const database = openTestJournalHostDatabase(root) + const freePages = () => Number(database.db.pragma('freelist_count', { simple: true })) + fillBlock(database.db, 0) + fillBlock(database.db, 1) + deleteJournalBlock(database.db, 0) + const pass = database.reclaimFreePages() + // The pass takes its first step, then a second chat's delete lands before its next one. + await new Promise((resolve) => setImmediate(resolve)) + expect(freePages()).toBeGreaterThan(0) + deleteJournalBlock(database.db, 1) + + expect(database.reclaimFreePages()).toBe(pass) + await pass + expect(freePages()).toBe(0) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index fca0095888f4..df63f9f2c006 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -8,7 +8,7 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' -import { reclaimFreePagesStep } from '../../sqlite/sqlite-free-page-reclaim' +import { freePageCount, reclaimFreePagesStep } from '../../sqlite/sqlite-free-page-reclaim' import { openJournalDatabase } from './journal-database' import type { JournalOwnerLock } from './journal-owner-lock' import { journalDirectoryFor } from './journal-paths' @@ -100,14 +100,14 @@ export class JournalHostDatabase { private async runReclaim(): Promise { try { await yieldToEventLoop() - let previous = Number.POSITIVE_INFINITY while (this.connection) { + const before = freePageCount(this.connection) const remaining = reclaimFreePagesStep(this.connection) - // A step that frees nothing (a file created without incremental auto-vacuum) ends the pass. - if (remaining === 0 || remaining >= previous) { + // A step that frees nothing (a file created without incremental auto-vacuum) ends the pass; + // pages a delete freed since the last step do not. + if (remaining === 0 || remaining >= before) { return } - previous = remaining await yieldToEventLoop() } } catch (error) { diff --git a/src/main/sqlite/sqlite-free-page-reclaim.ts b/src/main/sqlite/sqlite-free-page-reclaim.ts index 2da5d9dd6018..b887465370d3 100644 --- a/src/main/sqlite/sqlite-free-page-reclaim.ts +++ b/src/main/sqlite/sqlite-free-page-reclaim.ts @@ -8,11 +8,15 @@ import type SyncDatabase from './sync-database' export const RECLAIM_PAGES_PER_STEP = 2000 +export function freePageCount(db: SyncDatabase): number { + return Number(db.pragma('freelist_count', { simple: true }) ?? 0) +} + /** One bounded step, run to completion. Returns the pages still on the freelist. */ export function reclaimFreePagesStep( db: SyncDatabase, maxPages: number = RECLAIM_PAGES_PER_STEP ): number { db.pragma(`incremental_vacuum(${maxPages})`) - return Number(db.pragma('freelist_count', { simple: true }) ?? 0) + return freePageCount(db) } From 31062a5cccbd4432101367ea2fd68833a827df3a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:35:58 -0700 Subject: [PATCH 15/66] test(native-chat): desktop session history is served while chats are refused --- .../register-core-handlers.test.ts | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) 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 22fe84caba63..e855c3690cf2 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 @@ -400,8 +400,15 @@ vi.mock('../native-chat', () => ({ registerNativeChatHandlers: registerNativeChatHandlersMock })) +import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' +import { recordStructuredAgentSessionHostInstallRefusal } from '../../runtime/structured-agent-session-journal-ownership' import { registerCoreHandlers } from './register-core-handlers' +let registeredAiVaultOptions: { + ensureStructuredSessionOwnership: () => Promise +} +let registeredRuntime: { ensureStructuredAgentSessionHost: ReturnType } | undefined + describe('registerCoreHandlers', () => { beforeEach(() => { getPathMock.mockReset() @@ -471,7 +478,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' } @@ -507,6 +518,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, @@ -646,6 +660,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. From 1b778b6d282d6fa684b2a448a41f12ceee15588b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:37:02 -0700 Subject: [PATCH 16/66] fix(native-chat): a send to a chat holding a newer Orca's rows says to update A chat opened read-only because a newer Orca wrote rows to it answered a send with the generic write failure. It now refuses the way a database a newer Orca wrote does, with the same reason and words. --- .../journal-open-failure.ts | 11 +++++++++- .../agent-session-journal-recovery.test.ts | 20 +++++++++++++++++++ .../structured-agent-session-turns.ts | 9 +++++++-- 3 files changed, 37 insertions(+), 3 deletions(-) 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 b70226ea8238..57f4ab8ae6c1 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 @@ -11,6 +11,7 @@ import { } from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' import { JournalDatabaseNewerSchemaError } from './journal-database' +import { AgentSessionJournalError } from './journal-write-guards' export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> @@ -38,9 +39,17 @@ const JOURNAL_OPEN_MESSAGE: Record = { export const JOURNAL_NEWER_SCHEMA_MESSAGE = 'Chats were saved by a newer Orca. Update Orca to keep using them.' +/** 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 JournalDatabaseNewerSchemaError || + (error instanceof AgentSessionJournalError && error.code === 'journal_read_only') + ) +} + /** Why a journal open failed, and the words a released client prints for it. */ function journalOpenFailureWords(error: unknown): { reason: JournalOpenFailure; message: string } { - if (error instanceof JournalDatabaseNewerSchemaError) { + if (isJournalWrittenByNewerOrca(error)) { // Clears when this Orca is updated, so it is not damage. return { reason: 'journalUnavailable', message: JOURNAL_NEWER_SCHEMA_MESSAGE } } 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 27c08bbf0122..a989b0fdf3d2 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 @@ -26,6 +26,8 @@ import { openAgentSessionJournalWithRecovery, 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' // 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) => { @@ -223,6 +225,24 @@ describe('openAgentSessionJournalWithRecovery', () => { 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: 'journalUnavailable' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } + }) await withJournalDatabase(journalDir, (db) => { const rows = readTestJournalRows(db, CODEX_SESSION, epoch) expect(rows.some((entry) => entry.rowJson.includes('"v":99'))).toBe(true) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 49e57bd52232..aa1fcc2ad49c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -36,6 +36,7 @@ import { validatePendingPrompt } from './structured-agent-session-prompt-state' import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' import { classifyJournalOpenFailure, + isJournalWrittenByNewerOrca, journalOpenRefusal } from '../agent-session-journal/journal-open-failure' export { performSetOption } from './structured-agent-session-turns-options' @@ -144,8 +145,12 @@ export async function performSend( try { await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) } catch (error) { - // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. - if (classifyJournalOpenFailure(error) === 'journalCorrupt') { + // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So + // does a chat holding a newer Orca's rows, which only an update writes past. + if ( + classifyJournalOpenFailure(error) === 'journalCorrupt' || + isJournalWrittenByNewerOrca(error) + ) { return { ok: false, refusal: journalOpenRefusal(error) } } return invalid('journalWriteFailed', 'The message could not be recorded and was not sent.') From c6a891ea8b5fc47383bfb528d6dc785e747c1996 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:53:54 -0700 Subject: [PATCH 17/66] fix(native-chat): keep chat tabs while this process cannot list its chats A process whose chats another Orca owns, or whose chat journal will not open, has no structured host. Its session-tabs inventory still answered, with no chat rows, and the renderer read that as "every chat was closed": it removed the restored chat tabs and the next session save persisted their placement away. The inventory now says `agentSessionsUnverifiable` when the last tab restore ran with chats on disk but no host to list them. The flag is set and cleared at the per-client projection point beside the client-hosted page hold, and the restore is memoised only once a host answered, so a later lock takeover or journal open republishes the chats and clears it. The renderer keeps agent-session tabs, and keeps cancellation tombstones, against an inventory that does not affirm its chat set. --- ...-hosted-page-reconciliation-window.test.ts | 3 +- ...ime-apply-mobile-session-tab-navigation.ts | 4 +- ...ld-headless-mobile-session-browser-tabs.ts | 14 +- ...structured-agent-session-create-support.ts | 17 +- ...tore-structured-agent-session-tabs-once.ts | 3 + src/main/runtime/orca-runtime-runtime-id.ts | 3 + ...structured-agent-session-inventory-hold.ts | 20 ++ ...ed-agent-session-journal-ownership.test.ts | 92 ++++++++ ...-agent-session-launch-cancellation.test.ts | 54 +++++ .../host-session-snapshot-authority.ts | 16 ++ .../snapshot-apply.ts | 8 +- ...red-session-unverifiable-inventory.test.ts | 196 ++++++++++++++++++ .../apply-preparation-browser.ts | 10 +- src/shared/runtime-session-contracts.ts | 9 + 14 files changed, 433 insertions(+), 16 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-inventory-hold.ts create mode 100644 src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts diff --git a/src/main/runtime/client-hosted-page-reconciliation-window.test.ts b/src/main/runtime/client-hosted-page-reconciliation-window.test.ts index 7aa4562f986f..b52335dadf6e 100644 --- a/src/main/runtime/client-hosted-page-reconciliation-window.test.ts +++ b/src/main/runtime/client-hosted-page-reconciliation-window.test.ts @@ -142,10 +142,11 @@ describe('session-tabs projection census', () => { expect(source).toContain('this.clientSessionTabSelections.project(removed,') }) - it('keeps the unreconciled flag out of every other runtime publication site', () => { + it('keeps the hold flags out of every other runtime publication site', () => { const source = readOrcaRuntimeSourceFamily() expect(source).not.toContain('clientHostedPagesUnreconciled') + expect(source).not.toContain('agentSessionsUnverifiable') }) }) diff --git a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts index 68101c809609..055e64c4622f 100644 --- a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts @@ -40,7 +40,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW } for (const id of ids) { const projected = this.clientSessionTabSelections.activate( - this.withClientHostedPagesHold(snapshot, id), + this.withSessionTabsHolds(snapshot, id), id, activeTabId ) @@ -52,7 +52,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW } else if (clientNavigationId) { // Why: follow-host still starts as caller navigation; the host is an additional target, not a replacement owner. callerSnapshot = this.clientSessionTabSelections.activate( - this.withClientHostedPagesHold(snapshot, clientNavigationId), + this.withSessionTabsHolds(snapshot, clientNavigationId), clientNavigationId, activeTabId ) diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index e2e1e2337fcc..eaed3f2720ec 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -8,6 +8,7 @@ import type { } from '../../shared/runtime-types' import { parseAppSshPtyId } from '../../shared/ssh-pty-id' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' +import { holdAgentSessionInventory } from './structured-agent-session-inventory-hold' import type { Tab } from '../../shared/tab-types' import { resolveTerminalCloseTarget, @@ -293,26 +294,27 @@ export class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs extends OrcaRu /** * Answers one client's session-tabs question: whether this runtime has taken back *that* client's - * client-hosted pages yet, then that client's own tab selection. + * client-hosted pages yet and can say which chats exist, then that client's own tab selection. * - * The hold is decided here and nowhere else, and it is set or cleared rather than only set, so a - * frame built for one client can never carry another client's answer. + * The holds are decided here and nowhere else, and each is set or cleared rather than only set, + * so a frame built for one client can never carry another client's, or an earlier, answer. */ protected projectMobileSessionTabsForClient( result: RuntimeMobileSessionTabsResult, clientNavigationId?: string ): RuntimeMobileSessionTabsResult { return this.clientSessionTabSelections.project( - this.withClientHostedPagesHold(result, clientNavigationId), + this.withSessionTabsHolds(result, clientNavigationId), clientNavigationId ) } - protected withClientHostedPagesHold( + protected withSessionTabsHolds( result: RuntimeMobileSessionTabsResult, clientNavigationId: string | undefined ): RuntimeMobileSessionTabsResult { - return this.clientHostedPageReconciliation.holdFor(result, clientNavigationId, Date.now()) + const held = this.clientHostedPageReconciliation.holdFor(result, clientNavigationId, Date.now()) + return holdAgentSessionInventory(held, this.structuredAgentSessionInventoryUnverifiable) } protected async refreshMobileSessionPtyRecords( diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 41ebef8f8a27..b6a73a55ca42 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -238,10 +238,19 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR restoreStructuredAgentSessionTabs(): Promise { this.structuredAgentSessionTabRestorePromise ??= - this.restoreStructuredAgentSessionTabsOnce().catch((error) => { - this.structuredAgentSessionTabRestorePromise = null - throw error - }) + this.restoreStructuredAgentSessionTabsOnce().then( + () => { + // Only a host's answer is final: without one, the next caller restores again, so a later + // lock takeover or journal open republishes the chats. + if (this.structuredAgentSessionInventoryUnverifiable) { + this.structuredAgentSessionTabRestorePromise = null + } + }, + (error) => { + this.structuredAgentSessionTabRestorePromise = null + throw error + } + ) return this.structuredAgentSessionTabRestorePromise } diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index f22a09bac5e4..63e49e2ab4ec 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -86,6 +86,9 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu notify: false }) } + // No host means no one can say which chats exist; with none on disk, empty is the answer. + this.structuredAgentSessionInventoryUnverifiable = + !host && this.hasPersistedStructuredAgentSessionStore() } async publishStructuredAgentSessionTab(input: { diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index 4b252d57fefd..ecdb5939b5e3 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -120,6 +120,9 @@ export class OrcaRuntimeWithRuntimeId { protected structuredAgentSessionTabRestorePromise: Promise | null = null + // Whether the last tab restore ran with chats on disk but no host to list them. + protected structuredAgentSessionInventoryUnverifiable = false + protected structuredAgentSessionStartupRestorePromise: Promise | null = null protected mobileSessionTabsChangeSequence = 0 diff --git a/src/main/runtime/structured-agent-session-inventory-hold.ts b/src/main/runtime/structured-agent-session-inventory-hold.ts new file mode 100644 index 000000000000..9ab1e59aeb14 --- /dev/null +++ b/src/main/runtime/structured-agent-session-inventory-hold.ts @@ -0,0 +1,20 @@ +/** + * Answers whether a snapshot bound for a client may be read as this runtime's chat inventory. + * + * Sets *and* clears, like the client-hosted page hold beside it, because a frame is built once + * and may already carry an answer from before the last tab restore. + */ +export function holdAgentSessionInventory( + result: T, + unverifiable: boolean +): T { + if (unverifiable === (result.agentSessionsUnverifiable === true)) { + return result + } + if (unverifiable) { + return { ...result, agentSessionsUnverifiable: true as const } + } + const { agentSessionsUnverifiable: _held, ...released } = result + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: only the optional hold field was removed, so the rest is still a T. + return released as T +} diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index 8a7aedbcdc9c..0360b78fe71a 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -285,6 +285,98 @@ describe('startup and other non-chat work without a structured host', () => { }) }) + // The inventory must say "cannot tell", never "no chats": a client culls what an answer omits, + // and the desktop then saves its chat tabs away. + describe('the session-tabs inventory', () => { + /** The worktree's frame as the renderer's own graph publication leaves it: no chat rows. */ + function publishWorktreeFrame(runtime: OrcaRuntimeService): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected store, called as its graph publication does. + const internal = runtime as unknown as { + storeMobileSessionSnapshot(worktreeId: string, snapshot: unknown): unknown + } + internal.storeMobileSessionSnapshot('workspace-1', { + worktree: 'workspace-1', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + } + + async function listInventory(runtime: OrcaRuntimeService) { + await runtime.restoreStructuredAgentSessionTabs() + return runtime.listAllMobileSessionTabs() + } + + it('marks chats unverifiable while another process owns them', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toEqual([ + expect.objectContaining({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + ]) + }) + + it('marks chats unverifiable when the owner cannot open its journal', async () => { + await writeFile(journalDatabasePath(root), 'not a database '.repeat(512)) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toEqual([ + expect.objectContaining({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + ]) + }) + + it('lists no chats as a real answer once the owner has a host', async () => { + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toHaveLength(1) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(getStructuredAgentSessionHost()).not.toBeNull() + }) + + it('restores again after a takeover, publishing the chats and clearing the mark', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + expect((await listInventory(runtime))[0]).toMatchObject({ agentSessionsUnverifiable: true }) + + await holder.kill() + holder = null + await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) + // The first chat request after the takeover installs the host, as the read RPCs do. + const host = await install() + // The host's open conversations stand in for the chats its readable restore reopens. + vi.spyOn(host, 'listSessionTabs').mockReturnValue([ + { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' }, + { workspaceId: 'workspace-1', sessionId: 'codex-1', agent: 'codex' } + ]) + vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) + + const frames = await listInventory(runtime) + + expect(frames).toHaveLength(1) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(frames[0]?.tabs).toEqual([ + expect.objectContaining({ type: 'agent-session', sessionId: 'claude-1', agent: 'claude' }), + expect.objectContaining({ type: 'agent-session', sessionId: 'codex-1', agent: 'codex' }) + ]) + }) + }) + it('lets a terminal resume command through while another process owns the chats', async () => { holder = await holdJournalOwnerLockInChild(root) vi.spyOn(console, 'warn').mockImplementation(() => undefined) diff --git a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts index 7ba8dee68f8f..2295b8fd9589 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-cancellation.test.ts @@ -119,6 +119,60 @@ describe('structured launch cancellation retirement', () => { ) }) + // A runtime refused its chats answers with no chat rows; that absence is not the host's answer. + it('keeps a tombstone through an inventory that cannot list chats', async () => { + setStructuredLaunchState({ + identity: `codex:${WORKTREE_ID}`, + intent: { + worktreeId: WORKTREE_ID, + sessionId: SESSION_ID, + agent: 'codex', + params: { + envelope: { + sessionId: SESSION_ID, + clientOperationId: 'operation-unverifiable', + expectedRuntimeFence: null, + payloadFingerprint: 'fingerprint-unverifiable' + }, + worktree: `id:${WORKTREE_ID}`, + agent: 'codex' + } + }, + promptDelivery: 'auto-submit', + callers: { + outcome: 'pending', + entries: new Set(), + promptDeliveryResults: new Set(), + onSettled: () => undefined + }, + promise: Promise.resolve({ sessionId: SESSION_ID, fence: 1 }), + visibilityUnknown: false, + cancelled: false, + selection: { held: {} } + } satisfies StructuredLaunchState) + markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID) + await new Promise((resolve) => setTimeout(resolve, 0)) + const frame = (unverifiable: boolean): RuntimeMobileSessionTabsResult => ({ + ...latePublication(), + activeTabId: null, + activeTabType: null, + tabs: [], + ...(unverifiable ? { agentSessionsUnverifiable: true as const } : {}) + }) + const call = vi.fn() + Object.defineProperty(window, 'api', { configurable: true, value: { runtime: { call } } }) + + call.mockResolvedValue({ ok: true, result: { snapshots: [frame(true)], authoritative: true } }) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe(true) + + call.mockResolvedValue({ ok: true, result: { snapshots: [frame(false)], authoritative: true } }) + await refreshLocalStructuredSessionTabs() + expect(hasStructuredAgentSessionLaunchCancellationTombstone(WORKTREE_ID, SESSION_ID)).toBe( + false + ) + }) + it('drains a restored cancellation before a newer inventory retires it', async () => { markStructuredAgentSessionLaunchCancelled(WORKTREE_ID, SESSION_ID) resetStructuredAgentLaunchRegistryForTests() diff --git a/src/renderer/src/runtime/host-session-snapshot-authority.ts b/src/renderer/src/runtime/host-session-snapshot-authority.ts index 532d8497b861..8a894203bd30 100644 --- a/src/renderer/src/runtime/host-session-snapshot-authority.ts +++ b/src/renderer/src/runtime/host-session-snapshot-authority.ts @@ -9,6 +9,10 @@ type ClientHostedPagePublication = SnapshotPublication & { clientHostedPagesUnreconciled?: true } +type AgentSessionPublication = SnapshotPublication & { + agentSessionsUnverifiable?: true +} + /** * Whether a session-tabs snapshot carries the host's answer about a worktree at all. * @@ -42,3 +46,15 @@ export function hostSnapshotAffirmsClientHostedPages( ): boolean { return hostSnapshotAffirmsWorktreeContents(snapshot) && !snapshot.clientHostedPagesUnreconciled } + +/** + * Whether a snapshot's `agent-session` rows are the host's answer about which chats exist. + * + * Narrower than {@link hostSnapshotAffirmsWorktreeContents} for the same reason as client-hosted + * pages: a runtime refused its chats (another Orca owns them, or the journal will not open) is + * authoritative about terminals but cannot list a single chat. Its empty chat set is "cannot + * tell", and culling on it would delete tabs whose chats are safe on disk. + */ +export function hostSnapshotAffirmsAgentSessions(snapshot: AgentSessionPublication): boolean { + return hostSnapshotAffirmsWorktreeContents(snapshot) && !snapshot.agentSessionsUnverifiable +} diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts index 7928fcb5aca0..f9e35cb056b5 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts @@ -28,7 +28,10 @@ import { } from './inventory-generation-fence' import { forgetRetiredEpochRepairsOutside } from './retired-epoch-repair' import { projectLocalStructuredSessionTabs } from './snapshot-projection' -import { hostSnapshotAffirmsWorktreeContents } from '../host-session-snapshot-authority' +import { + hostSnapshotAffirmsAgentSessions, + hostSnapshotAffirmsWorktreeContents +} from '../host-session-snapshot-authority' import { hasStructuredAgentSessionLaunchCancellationTombstone, markStructuredAgentSessionLaunchPublished, @@ -92,6 +95,9 @@ export function applyStructuredSessionTabSnapshots( startStructuredAgentLaunchCancellationCleanup((sessionId) => closeStructuredAgentSession({ kind: 'local' }, sessionId) ) + } + // A chat missing from an inventory that cannot list chats is not proof the host dropped it. + if (options.authoritative && snapshots.every(hostSnapshotAffirmsAgentSessions)) { retireAbsentStructuredAgentSessionLaunchCancellationTombstones( new Set( snapshots.flatMap((snapshot) => diff --git a/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts b/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts new file mode 100644 index 000000000000..5159be9f6d3f --- /dev/null +++ b/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts @@ -0,0 +1,196 @@ +// @vitest-environment happy-dom + +// A runtime refused its chats (another Orca owns them, or the journal will not open) still answers +// the tab inventory, but cannot list a single chat. Its empty chat set must not delete the saved +// chat tabs, or the next session save persists their placement away. + +import { afterEach, describe, expect, it } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import type { Tab } from '../../../shared/tab-types' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-unified-tabs' +import { + applyLocalStructuredSessionTabSnapshots, + resetLocalStructuredSessionVersionForTests +} from './local-structured-session-tabs-sync' +import { + applyWebSessionTabsSnapshot, + resetWebSessionTabsSnapshotFreshnessForTests, + type WebSessionTabsSyncState +} from './web-session-tabs-sync' + +const WORKTREE_ID = 'repo-1::worktree-1' +const TERMINAL_ID = 'terminal-1' +const CLAUDE_TAB = 'agent-session:claude-1' +const CODEX_TAB = 'agent-session:codex-1' +const PRIMARY_GROUP = 'primary-group' +const CHAT_GROUP = 'chat-group' + +afterEach(() => { + resetLocalStructuredSessionVersionForTests() + resetWebSessionTabsSnapshotFreshnessForTests() +}) + +function chatTab(id: string, entityId: string, agent: 'claude' | 'codex', sortOrder: number): Tab { + return { + id, + entityId, + groupId: CHAT_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'agent-session', + agentSessionAgent: agent, + label: agent === 'claude' ? 'Claude Chat' : 'Codex Chat', + customLabel: null, + color: null, + sortOrder, + createdAt: sortOrder + 1 + } +} + +/** A terminal beside a split group holding a saved Claude chat and a saved Codex chat. */ +function hydratedState(): WebSessionTabsSyncState { + const tabs: Tab[] = [ + { + id: TERMINAL_ID, + entityId: TERMINAL_ID, + groupId: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + contentType: 'terminal', + label: 'Terminal', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + }, + chatTab(CLAUDE_TAB, 'claude-1', 'claude', 1), + chatTab(CODEX_TAB, 'codex-1', 'codex', 2) + ] + return { + activeBrowserTabId: null, + activeBrowserTabIdByWorktree: {}, + activeFileId: null, + activeFileIdByWorktree: {}, + activeGroupIdByWorktree: { [WORKTREE_ID]: CHAT_GROUP }, + activeTabId: CODEX_TAB, + activeTabIdByWorktree: { [WORKTREE_ID]: CODEX_TAB }, + activeTabType: 'agent-session', + activeTabTypeByWorktree: { [WORKTREE_ID]: 'agent-session' }, + activeWorktreeId: WORKTREE_ID, + agentStatusByPaneKey: {}, + agentStatusEpoch: 0, + browserCertificateFailuresByPageId: {}, + browserPagesByWorkspace: {}, + browserTabsByWorktree: {}, + groupsByWorktree: { + [WORKTREE_ID]: [ + { + id: PRIMARY_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: TERMINAL_ID, + tabOrder: [TERMINAL_ID] + }, + { + id: CHAT_GROUP, + worktreeId: WORKTREE_ID, + activeTabId: CODEX_TAB, + tabOrder: [CLAUDE_TAB, CODEX_TAB] + } + ] + }, + layoutByWorktree: { + [WORKTREE_ID]: { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: PRIMARY_GROUP }, + second: { type: 'leaf', groupId: CHAT_GROUP } + } + }, + openFiles: [], + ptyIdsByTabId: { [TERMINAL_ID]: ['pty-1'] }, + remoteBrowserPageHandlesByPageId: {}, + tabBarOrderByWorktree: { [WORKTREE_ID]: [TERMINAL_ID, CLAUDE_TAB, CODEX_TAB] }, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + unifiedTabsByWorktree: { [WORKTREE_ID]: tabs }, + unreadTerminalTabs: {}, + sortEpoch: 0 + } +} + +/** What a refused runtime publishes for the worktree: a real frame with no chat rows. */ +function frameWithoutChats(unverifiable: boolean): RuntimeMobileSessionTabsResult { + return { + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 3, + activeGroupId: PRIMARY_GROUP, + activeTabId: null, + activeTabType: null, + tabGroups: [{ id: PRIMARY_GROUP, activeTabId: null, tabOrder: [] }], + tabs: [], + ...(unverifiable ? { agentSessionsUnverifiable: true as const } : {}) + } +} + +function chatTabIds(state: WebSessionTabsSyncState): string[] { + return (state.unifiedTabsByWorktree[WORKTREE_ID] ?? []) + .filter((tab) => tab.contentType === 'agent-session') + .map((tab) => tab.id) +} + +function savedChatTabs(state: WebSessionTabsSyncState): Tab[] { + const session: WorkspaceSessionState = { + activeRepoId: null, + activeWorktreeId: WORKTREE_ID, + activeTabId: state.activeTabId, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + ...buildPersistedUnifiedTabSessionData(state) + } + return (session.unifiedTabs?.[WORKTREE_ID] ?? []).filter( + (tab) => tab.contentType === 'agent-session' + ) +} + +describe('an inventory that cannot list chats', () => { + it('keeps the saved chat tabs in place, and the next session save still has them', () => { + const before = hydratedState() + + const applied = applyLocalStructuredSessionTabSnapshots(before, [frameWithoutChats(true)]) + + expect(chatTabIds(applied)).toEqual([CLAUDE_TAB, CODEX_TAB]) + expect(applied.groupsByWorktree[WORKTREE_ID]).toEqual(before.groupsByWorktree[WORKTREE_ID]) + expect(applied.layoutByWorktree[WORKTREE_ID]).toEqual(before.layoutByWorktree[WORKTREE_ID]) + expect(applied.activeTabIdByWorktree[WORKTREE_ID]).toBe(CODEX_TAB) + expect(savedChatTabs(applied)).toEqual([ + expect.objectContaining({ id: CLAUDE_TAB, groupId: CHAT_GROUP, agentSessionAgent: 'claude' }), + expect.objectContaining({ id: CODEX_TAB, groupId: CHAT_GROUP, agentSessionAgent: 'codex' }) + ]) + }) + + // The mechanism the flag stands in front of: an affirmed chat set is the host's answer. + it('still removes chat tabs an affirmed inventory leaves out', () => { + const applied = applyLocalStructuredSessionTabSnapshots(hydratedState(), [ + frameWithoutChats(false) + ]) + + expect(chatTabIds(applied)).toEqual([]) + expect(savedChatTabs(applied)).toEqual([]) + }) + + it('keeps them on a paired client applying the same frame', () => { + const state = hydratedState() + + const patch = applyWebSessionTabsSnapshot( + state, + frameWithoutChats(true), + 'environment-1', + 1_700_000_000_000, + { contentScope: 'agent-session' } + ) + const applied = { ...state, ...patch } + + expect(chatTabIds(applied)).toEqual([CLAUDE_TAB, CODEX_TAB]) + expect(savedChatTabs(applied)).toHaveLength(2) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts index 4dcfd7ae44db..c88d7ebe1a69 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-browser.ts @@ -7,7 +7,10 @@ import { } from './mirrored-browser-tabs' import { buildMirroredEditorTabs } from './tab-builders' import { buildMirroredAgentTabs, isReadyBrowserTab, isReadyEditorTab } from './terminal-surfaces' -import { hostSnapshotAffirmsClientHostedPages } from '../host-session-snapshot-authority' +import { + hostSnapshotAffirmsAgentSessions, + hostSnapshotAffirmsClientHostedPages +} from '../host-session-snapshot-authority' import type { prepareWebSessionTabsSnapshotBase } from './apply-preparation-base' import type { OpenFile } from '../../store/slices/editor' import { @@ -39,6 +42,7 @@ export function prepareWebSessionTabsSnapshotBrowser( const publishedAgentSessionIds = new Set( snapshot.tabs.filter((tab) => tab.type === 'agent-session').map((tab) => tab.sessionId) ) + const agentSessionsAffirmed = hostSnapshotAffirmsAgentSessions(snapshot) const existingTabIndex = buildWebSessionExistingTabIndex({ unifiedTabs: currentUnifiedTabs }) const readyBrowserTabs = reconcilesNonAgentTabs ? snapshot.tabs.filter(isReadyBrowserTab) : [] const nextRemoteBrowserPageIds = new Set(readyBrowserTabs.map((tab) => tab.browserPageId)) @@ -177,9 +181,11 @@ export function prepareWebSessionTabsSnapshotBrowser( if (tab.contentType === 'agent-session') { // A matching host row is authoritative; retaining the provisional tab beside its mirror // would briefly render two panes before lifecycle publication is recorded. + // A host that cannot list its chats is no evidence this one closed. return ( !publishedAgentSessionIds.has(tab.entityId) && - shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId) + (!agentSessionsAffirmed || + shouldRetainStructuredAgentSessionLaunchTab(worktreeId, tab.entityId)) ) } if (tab.contentType === 'browser') { diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 393c95fec87f..7d8dba5849e0 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -264,6 +264,15 @@ export type RuntimeMobileSessionTabsResult = { * host that never returns cannot hold rows open forever. */ clientHostedPagesUnreconciled?: true + /** + * Set while this runtime has no structured-chat host that can say which chats exist: another + * Orca process owns them, or the chat journal will not open. The snapshot is still authoritative + * about everything else, but its missing `agent-session` rows mean "cannot tell", not "closed". + * + * Cleared by the first tab restore a host answers. Not bounded by a deadline: the chats are + * durable on disk, so holding their tabs strands nothing. + */ + agentSessionsUnverifiable?: true } export type RuntimeMobileSessionCreateTerminalResult = { From ea9ec6aa11fa9b6821489ae17a0fa1deb5dbd13d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:02:07 -0700 Subject: [PATCH 18/66] fix(native-chat): say chats are open in another Orca, with this process's way past it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A process refused because another Orca owns the profile's chats sent the generic `journalUnavailable` reason, so current desktop and phone surfaces said "couldn't open this chat's history right now. Try again." — a step that never helps while the other Orca runs. The refusal now names its own reason, `journalOwnedElsewhere`, with the refused process's kind (dev desktop, packaged, orcad) as a fact. Each kind gets its own step: quit the other Orca, or give this one its own profile (ORCA_DEV_USER_DATA_PATH) or data folder (ORCA_USER_DATA). The sentences are added to the shared notice copy, the desktop catalogs in all six locales, and the boot catalog. A client that predates the reason reads it as none and keeps the code's words; an unknown kind reads as the packaged app's step. The `message` released clients print is unchanged. Which requests refuse does not change. --- .../journal-open-failure.ts | 5 ++- ...ed-agent-session-journal-ownership.test.ts | 10 ++--- ...uctured-agent-session-journal-ownership.ts | 6 ++- .../agent-session-write-notice-text.ts | 14 +++++++ .../src/i18n/en-runtime-required.json | 4 ++ src/renderer/src/i18n/locales/en.json | 4 ++ src/renderer/src/i18n/locales/es.json | 4 ++ src/renderer/src/i18n/locales/fr.json | 4 ++ src/renderer/src/i18n/locales/ja.json | 4 ++ src/renderer/src/i18n/locales/ko.json | 4 ++ src/renderer/src/i18n/locales/zh.json | 4 ++ src/shared/agent-session-refusal-details.ts | 38 +++++++++++++++---- .../agent-session-refusal-notice.test.ts | 29 +++++++++++++- src/shared/agent-session-refusal-notice.ts | 14 ++++++- src/shared/agent-session-write-failure.ts | 6 ++- src/shared/agent-session-write-notice-copy.ts | 17 +++++++++ 16 files changed, 148 insertions(+), 19 deletions(-) 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 57f4ab8ae6c1..0e4a1e1985d3 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 @@ -13,7 +13,10 @@ import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' import { JournalDatabaseNewerSchemaError } from './journal-database' import { AgentSessionJournalError } from './journal-write-guards' -export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> +export type JournalOpenFailure = Exclude< + AgentSessionRefusalReason<'agent_session_journal_unreadable'>, + 'journalOwnedElsewhere' +> // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index 0360b78fe71a..60a8a890b00d 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -102,11 +102,11 @@ describe('a second process on the same profile', () => { refusal: { code: 'agent_session_journal_unreadable', message: JOURNAL_OWNER_REFUSAL_MESSAGES[kind], - details: { reason: 'journalUnavailable' } + details: { reason: 'journalOwnedElsewhere', processKind: kind } } }) expect(gateRefusal()).toEqual({ - reason: 'journalUnavailable', + reason: 'journalOwnedElsewhere', message: JOURNAL_OWNER_REFUSAL_MESSAGES[kind] }) expect(getStructuredAgentSessionHost()).toBeNull() @@ -123,7 +123,7 @@ describe('a second process on the same profile', () => { holder = await holdJournalOwnerLockInChild(root) vi.spyOn(console, 'warn').mockImplementation(() => undefined) await expect(install()).rejects.toMatchObject({ - refusal: { details: { reason: 'journalUnavailable' } } + refusal: { details: { reason: 'journalOwnedElsewhere' } } }) await holder.kill() @@ -249,7 +249,7 @@ describe('startup and other non-chat work without a structured host', () => { allowAttachedWindow: true, onlyRuntimeOwnedTerminals: true }) - expect(gateRefusal().reason).toBe('journalUnavailable') + expect(gateRefusal().reason).toBe('journalOwnedElsewhere') }) it('goes ahead when the owner cannot open its journal', async () => { @@ -392,7 +392,7 @@ describe('startup and other non-chat work without a structured host', () => { throw new Error('the record store would not open') }) ).rejects.toThrow('the record store would not open') - expect(gateRefusal().reason).toBe('journalUnavailable') + expect(gateRefusal().reason).toBe('journalOwnedElsewhere') }) it('still fails on an install error that refuses nothing', async () => { diff --git a/src/main/runtime/structured-agent-session-journal-ownership.ts b/src/main/runtime/structured-agent-session-journal-ownership.ts index c6430c6b2082..a346058a02ad 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.ts @@ -7,6 +7,7 @@ // releases the previous one first. import { resolve } from 'node:path' +import type { AgentSessionJournalProcessKind } from '../../shared/agent-session-refusal-details' import { agentSessionRefusalError, isAgentSessionRefusalError, @@ -20,8 +21,9 @@ import { } from '../native-chat/agent-session-journal/journal-owner-lock' /** Which way the refused process can be told to get out of the way. */ -export type JournalOwnerProcessKind = 'dev-desktop' | 'packaged' | 'orcad' +export type JournalOwnerProcessKind = AgentSessionJournalProcessKind +// For logs and released clients; current clients pick their words from the reason and kind. export const JOURNAL_OWNER_REFUSAL_MESSAGES: Record = { 'dev-desktop': 'Chats are open in another Orca window using this profile. Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).', @@ -85,7 +87,7 @@ export function structuredAgentSessionJournalOwnerRefusal(): AgentSessionRefusal } return agentSessionRefusalError( 'agent_session_journal_unreadable', - { reason: 'journalUnavailable' }, + { reason: 'journalOwnedElsewhere', processKind }, JOURNAL_OWNER_REFUSAL_MESSAGES[processKind] ) } diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 812197d6e336..c202fcd49522 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -109,6 +109,20 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.chatNotFound', COPY.chatNotFound), startNewChat: () => translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat), + chatsOpenElsewhere: () => + translate('components.native-chat.writeNotice.chatsOpenElsewhere', COPY.chatsOpenElsewhere), + quitOtherOrca: () => + translate('components.native-chat.writeNotice.quitOtherOrca', COPY.quitOtherOrca), + quitOtherOrcaOrUseDevProfile: () => + translate( + 'components.native-chat.writeNotice.quitOtherOrcaOrUseDevProfile', + COPY.quitOtherOrcaOrUseDevProfile + ), + stopOtherOrcaOrUseDataFolder: () => + translate( + 'components.native-chat.writeNotice.stopOtherOrcaOrUseDataFolder', + COPY.stopOtherOrcaOrUseDataFolder + ), tryAgain: () => translate('components.native-chat.writeNotice.tryAgain', COPY.tryAgain) } diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 1e6ca9ecd659..6c584cfbc41b 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2752,6 +2752,10 @@ "restartFailed": "The agent couldn't restart.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", "startNewChat": "Start a new chat to continue.", + "chatsOpenElsewhere": "Chats are open in another Orca using this profile.", + "quitOtherOrca": "Quit that Orca to use chats here.", + "quitOtherOrcaOrUseDevProfile": "Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).", + "stopOtherOrcaOrUseDataFolder": "Stop that Orca to use chats here, or give this one its own data folder (ORCA_USER_DATA).", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", "tryAgain": "Try again.", "tryAgainComposerSend": "Send it again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index cc182bd97346..521c2b87a827 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17524,6 +17524,10 @@ "recordUnreadable": "Orca couldn't read this chat's saved state.", "chatNotFound": "The Orca running this chat has no record of it.", "startNewChat": "Start a new chat to continue.", + "chatsOpenElsewhere": "Chats are open in another Orca using this profile.", + "quitOtherOrca": "Quit that Orca to use chats here.", + "quitOtherOrcaOrUseDevProfile": "Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).", + "stopOtherOrcaOrUseDataFolder": "Stop that Orca to use chats here, or give this one its own data folder (ORCA_USER_DATA).", "tryAgain": "Try again." }, "taskList": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 42417b214f30..8e3ec9f8bfe4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14975,6 +14975,10 @@ "recordUnreadable": "Orca no pudo leer el estado guardado de este chat.", "chatNotFound": "La versión de Orca que ejecuta este chat no tiene ningún registro de él.", "startNewChat": "Inicie un chat nuevo para continuar.", + "chatsOpenElsewhere": "Los chats están abiertos en otro Orca que usa este perfil.", + "quitOtherOrca": "Cierre ese Orca para usar los chats aquí.", + "quitOtherOrcaOrUseDevProfile": "Cierre ese Orca para usar los chats aquí, o inicie este con su propio perfil (ORCA_DEV_USER_DATA_PATH).", + "stopOtherOrcaOrUseDataFolder": "Detenga ese Orca para usar los chats aquí, o asigne a este su propia carpeta de datos (ORCA_USER_DATA).", "tryAgain": "Vuelva a intentarlo." }, "composer": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 115bd685b56a..46aed5d1d61d 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17458,6 +17458,10 @@ "recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.", "chatNotFound": "L'instance d'Orca qui exécute ce chat n'en a aucune trace.", "startNewChat": "Démarrez un nouveau chat pour continuer.", + "chatsOpenElsewhere": "Les chats sont ouverts dans un autre Orca qui utilise ce profil.", + "quitOtherOrca": "Quittez cet Orca pour utiliser les chats ici.", + "quitOtherOrcaOrUseDevProfile": "Quittez cet Orca pour utiliser les chats ici, ou démarrez celui-ci avec son propre profil (ORCA_DEV_USER_DATA_PATH).", + "stopOtherOrcaOrUseDataFolder": "Arrêtez cet Orca pour utiliser les chats ici, ou attribuez à celui-ci son propre dossier de données (ORCA_USER_DATA).", "tryAgain": "Réessayez." }, "composer": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index fc54f4c97d58..2b9d1efb30fa 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17394,6 +17394,10 @@ "recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。", "chatNotFound": "このチャットを実行している Orca には、このチャットの記録がありません。", "startNewChat": "続けるには、新しいチャットを開始してください。", + "chatsOpenElsewhere": "このプロファイルを使用している別の Orca でチャットが開かれています。", + "quitOtherOrca": "ここでチャットを使うには、その Orca を終了してください。", + "quitOtherOrcaOrUseDevProfile": "ここでチャットを使うには、その Orca を終了するか、この Orca を専用のプロファイル (ORCA_DEV_USER_DATA_PATH) で起動してください。", + "stopOtherOrcaOrUseDataFolder": "ここでチャットを使うには、その Orca を停止するか、この Orca に専用のデータフォルダー (ORCA_USER_DATA) を指定してください。", "tryAgain": "もう一度お試しください。" }, "composer": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 23bddadf1399..3b08e3270dcc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17394,6 +17394,10 @@ "recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.", "chatNotFound": "이 채팅을 실행하는 Orca에 이 채팅의 기록이 없습니다.", "startNewChat": "계속하려면 새 채팅을 시작하세요.", + "chatsOpenElsewhere": "이 프로필을 사용하는 다른 Orca에서 채팅이 열려 있습니다.", + "quitOtherOrca": "여기에서 채팅을 사용하려면 해당 Orca를 종료하세요.", + "quitOtherOrcaOrUseDevProfile": "여기에서 채팅을 사용하려면 해당 Orca를 종료하거나, 이 Orca를 별도의 프로필(ORCA_DEV_USER_DATA_PATH)로 시작하세요.", + "stopOtherOrcaOrUseDataFolder": "여기에서 채팅을 사용하려면 해당 Orca를 중지하거나, 이 Orca에 별도의 데이터 폴더(ORCA_USER_DATA)를 지정하세요.", "tryAgain": "다시 시도하세요." }, "composer": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 1356f6b8cf6b..8467cb4eff72 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17359,6 +17359,10 @@ "recordUnreadable": "Orca 无法读取此聊天的已保存状态。", "chatNotFound": "运行此聊天的 Orca 没有此聊天的记录。", "startNewChat": "请开始新的聊天以继续。", + "chatsOpenElsewhere": "聊天已在使用此配置文件的另一个 Orca 中打开。", + "quitOtherOrca": "请退出该 Orca,以便在此处使用聊天。", + "quitOtherOrcaOrUseDevProfile": "请退出该 Orca 以在此处使用聊天,或使用独立的配置文件(ORCA_DEV_USER_DATA_PATH)启动此 Orca。", + "stopOtherOrcaOrUseDataFolder": "请停止该 Orca 以在此处使用聊天,或为此 Orca 指定独立的数据文件夹(ORCA_USER_DATA)。", "tryAgain": "请重试。" }, "composer": { diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 6080a772ee2a..8950b3bac6b1 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -95,7 +95,9 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** SQLite reports the chat's journal damaged or not a database; no retry reads past it. */ 'journalCorrupt', /** Any other failed open, which can clear. */ - 'journalUnavailable' + 'journalUnavailable', + /** Another Orca process on this profile owns the chats; it clears when that one quits. */ + 'journalOwnedElsewhere' ], structured_agent_session_unsupported: [ 'clientCapabilityMissing', @@ -126,6 +128,15 @@ type PromptFacts = { type NoFacts = Record +/** How the process refused a profile's chats was started, which decides its way past the owner. */ +export type AgentSessionJournalProcessKind = 'dev-desktop' | 'packaged' | 'orcad' + +const JOURNAL_PROCESS_KINDS: readonly AgentSessionJournalProcessKind[] = [ + 'dev-desktop', + 'packaged', + 'orcad' +] + /** The facts a code carries beside its reason. */ type AgentSessionRefusalFactsByCode = { agent_session_operation_invalid: RewindFacts @@ -143,7 +154,10 @@ type AgentSessionRefusalFactsByCode = { agent_session_operation_conflict: NoFacts agent_session_operation_expired: NoFacts agent_session_operation_capacity: NoFacts - agent_session_journal_unreadable: NoFacts + agent_session_journal_unreadable: { + /** Sent with `journalOwnedElsewhere`: the refused process, not the owner. */ + processKind?: AgentSessionJournalProcessKind + } structured_agent_session_unsupported: NoFacts agent_session_owner_restart_failed: NoFacts } @@ -180,14 +194,20 @@ export type AgentSessionLegacyRefusalFields = { rewindReason?: AgentSessionRewindReason } +/** Every fact a reader keeps: the legacy fields, and the ones only details carry. */ +type AgentSessionRefusalFactFields = AgentSessionLegacyRefusalFields & { + processKind?: AgentSessionJournalProcessKind +} + const FACT_KEYS_BY_CODE: Partial< - Record + Record > = { agent_session_operation_invalid: ['rewindReason'], agent_session_operation_unknown: ['rewindReason'], agent_session_checkpoint_stale: ['currentFence'], agent_session_item_revision_stale: ['currentRevision', 'resolution'], - agent_session_already_resolved: ['currentRevision', 'resolution'] + agent_session_already_resolved: ['currentRevision', 'resolution'], + agent_session_journal_unreadable: ['processKind'] } const OWNER_VERDICTS: readonly AgentSessionOwnerVerdict[] = ['live', 'unverifiable', 'exited'] @@ -197,9 +217,9 @@ function isRecord(value: unknown): value is Record { } function readFact( - key: keyof AgentSessionLegacyRefusalFields, + key: keyof AgentSessionRefusalFactFields, value: unknown -): AgentSessionLegacyRefusalFields { +): AgentSessionRefusalFactFields { switch (key) { case 'currentFence': return Number.isSafeInteger(value) && typeof value === 'number' ? { currentFence: value } : {} @@ -217,6 +237,10 @@ function readFact( const rewindReason = AGENT_SESSION_REWIND_REASONS.find((reason) => reason === value) return rewindReason ? { rewindReason } : {} } + case 'processKind': { + const processKind = JOURNAL_PROCESS_KINDS.find((kind) => kind === value) + return processKind ? { processKind } : {} + } } } @@ -241,7 +265,7 @@ export function readAgentSessionRefusalDetails( + const facts = keys.reduce( (kept, key) => ({ ...kept, ...readFact(key, value[key]) }), {} ) diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 73652adbf1ad..89e831ff89b9 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -382,8 +382,11 @@ describe('the notice for every reason a host names', () => { const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) const answeredAway = write === 'answer' && parts.includes('questionChanged') const unsupported = failure.code === 'structured_agent_session_unsupported' + // Every journal cause says the history did not load, except one naming another Orca. const saysNotDone = - write === 'read-history' && failure.code === 'agent_session_journal_unreadable' + write === 'read-history' && + failure.code === 'agent_session_journal_unreadable' && + failure.details?.reason !== 'journalOwnedElsewhere' expect(notDone, cell).toEqual( answeredAway || unsupported || saysNotDone ? [] : [NOT_DONE[write]] ) @@ -499,6 +502,30 @@ describe('a chat whose history the host could not open', () => { expect(notice({ reason }, write)).toBe(expected) }) + // The step is this process's own way past the owner, by how it was started. A kind this build + // does not know, or none from a host that sent it, reads as the packaged app's. + it.each([ + [ + 'dev-desktop', + 'Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).' + ], + ['packaged', 'Quit that Orca to use chats here.'], + [ + 'orcad', + 'Stop that Orca to use chats here, or give this one its own data folder (ORCA_USER_DATA).' + ], + [undefined, 'Quit that Orca to use chats here.'], + ['a-kind-from-a-newer-host', 'Quit that Orca to use chats here.'] + ])('says chats are open in another Orca, for process kind %s', (processKind, step) => { + const details = { reason: 'journalOwnedElsewhere', processKind } + expect(notice(details, 'read-history')).toBe( + `Chats are open in another Orca using this profile. This chat's history couldn't be loaded. ${step}` + ) + expect(notice(details, 'send')).toBe( + `Chats are open in another Orca using this profile. Your message was not sent. ${step}` + ) + }) + // Damage can't be told apart from an open that can clear, so it never says to start over. it.each([ ['an older host', undefined], diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 818031b81915..90731e2f0070 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -14,6 +14,7 @@ import { agentSessionFailureSentence } from './agent-session-failure-words' import type { AgentSessionRefusalReason } from './agent-session-refusal-details' import { AGENT_SESSION_WRITE_NOTICE_COPY, + JOURNAL_OWNED_ELSEWHERE_STEP, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' @@ -198,7 +199,9 @@ const REASON_WORDS = { // No retry reads past damage, and the words name no step: it only can't load. journalCorrupt: causeWords('historyUnusable', 'hostFinding'), // Says its step despite 'retry': released clients and the phone often show no Retry here. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), + // The step is the packaged app's; `processKind` picks another way past the owner. + journalOwnedElsewhere: causeWords('chatsOpenElsewhere', 'actFirst', 'quitOtherOrca') }, // Thrown, so a client meets these only as an RPC error; the code's words stand. structured_agent_session_unsupported: { @@ -221,7 +224,14 @@ export function agentSessionRefusalReasonWords( const reason = failure.details?.reason const byReason: Partial> | undefined = REASON_WORDS[failure.code] - return reason === undefined ? undefined : byReason?.[reason] + const words = reason === undefined ? undefined : byReason?.[reason] + const kind = + failure.code === 'agent_session_journal_unreadable' && reason === 'journalOwnedElsewhere' + ? failure.details?.processKind + : undefined + return kind && words && 'cause' in words + ? { ...words, step: JOURNAL_OWNED_ELSEWHERE_STEP[kind] } + : words } // Each already says the history was not read. diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts index dd5c8f26caa2..f178e165008c 100644 --- a/src/shared/agent-session-write-failure.ts +++ b/src/shared/agent-session-write-failure.ts @@ -4,6 +4,7 @@ import { readAgentSessionRefusalDetails, + type AgentSessionJournalProcessKind, type AgentSessionRefusalReason } from './agent-session-refusal-details' import type { AgentSessionRewindReason } from './agent-session-rewind' @@ -63,12 +64,15 @@ type DurableRefusalFacts = { agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason } /** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */ agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict } + /** How the refused process was started, which picks its step past the owner. */ + agent_session_journal_unreadable: { processKind?: AgentSessionJournalProcessKind } } const DURABLE_FACT_KEYS: Partial> = { agent_session_operation_invalid: ['rewindReason'], agent_session_operation_unknown: ['rewindReason'], - agent_session_ownership_unknown: ['ownerVerdict'] + agent_session_ownership_unknown: ['ownerVerdict'], + agent_session_journal_unreadable: ['processKind'] } satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] } export type AgentSessionWriteRefusalDetails = { diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index d0c36e27a407..baf2bc0b55b2 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -1,5 +1,6 @@ // The sentences a chat notice is made of, each whole so desktop can translate it on its own. +import type { AgentSessionJournalProcessKind } from './agent-session-refusal-details' import { QUIT_TERMINAL_AGENT, START_NEW_CHAT, @@ -55,6 +56,12 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { waitMoment: 'Wait a moment.', recordUnreadable: "Orca couldn't read this chat's saved state.", chatNotFound: 'The Orca running this chat has no record of it.', + chatsOpenElsewhere: 'Chats are open in another Orca using this profile.', + quitOtherOrca: 'Quit that Orca to use chats here.', + quitOtherOrcaOrUseDevProfile: + 'Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).', + stopOtherOrcaOrUseDataFolder: + 'Stop that Orca to use chats here, or give this one its own data folder (ORCA_USER_DATA).', startNewChat: START_NEW_CHAT, tryAgain: 'Try again.' } as const @@ -62,3 +69,13 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY /** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } + +/** A process refused its profile's chats gets past the owner by how it was started. */ +export const JOURNAL_OWNED_ELSEWHERE_STEP: Record< + AgentSessionJournalProcessKind, + AgentSessionWriteNoticeSentence +> = { + 'dev-desktop': 'quitOtherOrcaOrUseDevProfile', + packaged: 'quitOtherOrca', + orcad: 'stopOtherOrcaOrUseDataFolder' +} From 0303e4c2350c8fdd1704f419357a20a6e288a2e3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:23:58 -0700 Subject: [PATCH 19/66] fix(native-chat): restore chats on taking ownership, without a list to ask A refused startup kept its hostless result, so after the owner quit this process never installed a host, never reconciled restart leases, and kept telling clients it could not list its chats until a desktop chat request. Taking the lock now reruns startup restoration once and pushes the chats. --- ...structured-agent-session-create-support.ts | 44 +++++++++++- src/main/runtime/orca-runtime-runtime-id.ts | 2 + ...ed-agent-session-journal-ownership.test.ts | 72 ++++++++++++------- 3 files changed, 90 insertions(+), 28 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index b6a73a55ca42..18f0544140ff 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -19,7 +19,11 @@ import { } from './structured-agent-account-home' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' -import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-journal-ownership' +import { + ensureStructuredAgentSessionHostUnlessRefused, + onStructuredAgentSessionJournalOwned, + structuredAgentSessionJournalOwnerRefusal +} from './structured-agent-session-journal-ownership' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -272,8 +276,44 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR await ensureStructuredAgentSessionHostUnlessRefused(() => this.ensureStructuredAgentSessionHost() ) + // Read now: a host a chat request installs later is reconciled by the takeover's own run. + const host = getStructuredAgentSessionHost() + if (!host && structuredAgentSessionJournalOwnerRefusal()) { + this.restoreAgainOnceJournalOwned() + } await this.refreshMobileSessionPtyRecords() - await getStructuredAgentSessionHost()?.reconcileRestartLeases() + await host?.reconcileRestartLeases() + } + + /** A takeover makes this process the owner: startup restoration runs once more, now with a + * host, and a client told "cannot tell" gets the chats pushed, since nothing lists on its own. */ + protected restoreAgainOnceJournalOwned(): void { + this.stopAwaitingStructuredAgentSessionJournal?.() + this.stopAwaitingStructuredAgentSessionJournal = onStructuredAgentSessionJournalOwned(() => { + this.stopAwaitingStructuredAgentSessionJournal?.() + this.stopAwaitingStructuredAgentSessionJournal = null + this.restoreStructuredAgentSessionsAfterTakeover().catch((error) => { + console.error( + '[structured-agent-session] restoring chats after taking ownership failed', + error + ) + }) + }) + } + + protected async restoreStructuredAgentSessionsAfterTakeover(): Promise { + const refusedStartup = this.structuredAgentSessionStartupRestorePromise + await Promise.allSettled([refusedStartup, this.structuredAgentSessionTabRestorePromise]) + if (this.structuredAgentSessionStartupRestorePromise === refusedStartup) { + this.structuredAgentSessionStartupRestorePromise = null + } + if (!this.structuredAgentSessionInventoryUnverifiable) { + await this.prepareStructuredAgentSessionStartupRestoration() + return + } + await this.restoreStructuredAgentSessionTabs() + // The restore publishes quietly; subscribers still hold the frames that said "cannot tell". + this.notifyMobileSessionTabSnapshots() } protected hasPersistedStructuredAgentSessionStore(): boolean { diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index ecdb5939b5e3..205d187c2bfe 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -125,6 +125,8 @@ export class OrcaRuntimeWithRuntimeId { protected structuredAgentSessionStartupRestorePromise: Promise | null = null + protected stopAwaitingStructuredAgentSessionJournal: (() => void) | null = null + protected mobileSessionTabsChangeSequence = 0 protected sessionTabsInventoryPublicationEpoch: number | null = null diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index 60a8a890b00d..041d94fb240f 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -20,6 +20,7 @@ import { import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../native-chat/agent-session-journal/journal-open-failure' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import Database from '../sqlite/sync-database' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' @@ -39,6 +40,8 @@ import { let root: string let holder: JournalOwnerLockHolder | null = null +// A refused startup waits for ownership; a later test's claim must not wake an earlier runtime. +const stopAwaitingOwnership: (() => void)[] = [] // An in-process caller: the same build as the host, so the gate asks it for no capability. // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. @@ -80,6 +83,7 @@ beforeEach(async () => { }) afterEach(async () => { + stopAwaitingOwnership.splice(0).forEach((stop) => stop()) await holder?.kill() holder = null await stopStructuredAgentSessionRuntime().catch(() => undefined) @@ -221,12 +225,14 @@ describe('startup and other non-chat work without a structured host', () => { refreshMobileSessionPtyRecords(): Promise | null> getKnownWorkspaceSessionWorktreeIds(): Set hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + stopAwaitingStructuredAgentSessionJournal: (() => void) | null } internal.hasPersistedStructuredAgentSessionStore = () => true internal.ensureStructuredAgentSessionHost = ensureHost internal.refreshMobileSessionPtyRecords = refreshPtyRecords internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrateTabs + stopAwaitingOwnership.push(() => internal.stopAwaitingStructuredAgentSessionJournal?.()) return { runtime, refreshPtyRecords, hydrateTabs } } @@ -289,20 +295,22 @@ describe('startup and other non-chat work without a structured host', () => { // and the desktop then saves its chat tabs away. describe('the session-tabs inventory', () => { /** The worktree's frame as the renderer's own graph publication leaves it: no chat rows. */ + const WORKTREE_FRAME: RuntimeMobileSessionTabsResult = { + worktree: 'workspace-1', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + function publishWorktreeFrame(runtime: OrcaRuntimeService): void { // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected store, called as its graph publication does. const internal = runtime as unknown as { storeMobileSessionSnapshot(worktreeId: string, snapshot: unknown): unknown } - internal.storeMobileSessionSnapshot('workspace-1', { - worktree: 'workspace-1', - publicationEpoch: 'renderer-epoch', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - }) + internal.storeMobileSessionSnapshot('workspace-1', WORKTREE_FRAME) } async function listInventory(runtime: OrcaRuntimeService) { @@ -347,33 +355,45 @@ describe('startup and other non-chat work without a structured host', () => { expect(getStructuredAgentSessionHost()).not.toBeNull() }) - it('restores again after a takeover, publishing the chats and clearing the mark', async () => { + it('restores on its own after a takeover, pushing the chats without the mark', async () => { holder = await holdJournalOwnerLockInChild(root) vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime } = startupRuntime() + const reconciled = vi.fn(async () => undefined) + // The real install; its host's open conversations stand in for the chats its readable + // restore reopens. + const { runtime, refreshPtyRecords } = startupRuntime(async () => { + const host = await install() + vi.spyOn(host, 'listSessionTabs').mockReturnValue([ + { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' }, + { workspaceId: 'workspace-1', sessionId: 'codex-1', agent: 'codex' } + ]) + vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) + vi.spyOn(host, 'reconcileRestartLeases').mockImplementation(reconciled) + return host + }) publishWorktreeFrame(runtime) expect((await listInventory(runtime))[0]).toMatchObject({ agentSessionsUnverifiable: true }) + // A paired phone's subscription: nothing on the desktop lists or opens a chat after this. + const pushed: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) await holder.kill() holder = null - await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) - // The first chat request after the takeover installs the host, as the read RPCs do. - const host = await install() - // The host's open conversations stand in for the chats its readable restore reopens. - vi.spyOn(host, 'listSessionTabs').mockReturnValue([ - { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' }, - { workspaceId: 'workspace-1', sessionId: 'codex-1', agent: 'codex' } - ]) - vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) - const frames = await listInventory(runtime) - - expect(frames).toHaveLength(1) - expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') - expect(frames[0]?.tabs).toEqual([ + const chats = [ expect.objectContaining({ type: 'agent-session', sessionId: 'claude-1', agent: 'claude' }), expect.objectContaining({ type: 'agent-session', sessionId: 'codex-1', agent: 'codex' }) - ]) + ] + await vi.waitFor(() => expect(pushed.at(-1)?.tabs).toEqual(chats), { timeout: 10_000 }) + unsubscribe() + expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') + expect(getStructuredAgentSessionHost()).not.toBeNull() + expect(reconciled).toHaveBeenCalledOnce() + expect(refreshPtyRecords).toHaveBeenCalledTimes(2) + const frames = await runtime.listAllMobileSessionTabs() + expect(frames).toEqual([expect.objectContaining({ tabs: chats })]) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(reconciled).toHaveBeenCalledOnce() }) }) From e974fa1f01199670ce6fc4875f941fb906e012b5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:23:59 -0700 Subject: [PATCH 20/66] test(native-chat): a navigation reply says chats are unverifiable while refused --- ...ed-agent-session-journal-ownership.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index 041d94fb240f..ce3fb29f48e0 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -20,6 +20,7 @@ import { import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../native-chat/agent-session-journal/journal-open-failure' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import Database from '../sqlite/sync-database' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' @@ -344,6 +345,36 @@ describe('startup and other non-chat work without a structured host', () => { ]) }) + // A tap's reply goes straight back to the tapping client, not through the list. + it.each(['caller', 'clients'])( + 'marks a %s navigation reply unverifiable while another process owns the chats', + async (navigation) => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + await listInventory(runtime) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected navigation step, called as `session.tabs.activate` does. + const internal = runtime as unknown as { + applyMobileSessionTabNavigation( + snapshot: RuntimeMobileSessionTabsResult, + activeTabId: string, + navigation: RuntimeNavigationTarget, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult + } + + const reply = internal.applyMobileSessionTabNavigation( + WORKTREE_FRAME, + 'agent-session:claude-1', + navigation, + 'phone-1' + ) + + expect(reply).toMatchObject({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + } + ) + it('lists no chats as a real answer once the owner has a host', async () => { const { runtime } = startupRuntime() publishWorktreeFrame(runtime) From dbf2eefc5ae20a76a4435d921d8fb0556cad7630 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:25:54 -0700 Subject: [PATCH 21/66] fix(native-chat): retry a refused owner lock at most every 5 seconds The lock frees as its holder exits, but a refused process only learns that on its next retry, and the 30 s cap left a second Orca refusing chats for up to half a minute after the owner quit. One retry is an open and BEGIN EXCLUSIVE on an empty file. --- .../agent-session-journal/journal-owner-lock.test.ts | 4 ++-- .../agent-session-journal/journal-owner-lock.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts index 715508a85a17..97c202b4ed1e 100644 --- a/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts @@ -93,7 +93,7 @@ describe('the owner lock', () => { }) describe('retrying a refused lock', () => { - it('backs off from one second to thirty and stops once it is granted', () => { + it('backs off from one second to five and stops once it is granted', () => { const delays: number[] = [] const pending: (() => void)[] = [] let grantOn = 7 @@ -113,7 +113,7 @@ describe('retrying a refused lock', () => { while (pending.length > 0) { pending.shift()!() } - expect(delays).toEqual([1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000]) + expect(delays).toEqual([1_000, 2_000, 4_000, 5_000, 5_000, 5_000, 5_000]) expect(granted).toEqual([fakeLock]) }) diff --git a/src/main/native-chat/agent-session-journal/journal-owner-lock.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock.ts index 4b8bb75e6676..24f2bdb3f975 100644 --- a/src/main/native-chat/agent-session-journal/journal-owner-lock.ts +++ b/src/main/native-chat/agent-session-journal/journal-owner-lock.ts @@ -50,12 +50,12 @@ export class JournalOwnerLock { } const RETRY_FIRST_DELAY_MS = 1_000 -const RETRY_MAX_DELAY_MS = 30_000 +const RETRY_MAX_DELAY_MS = 5_000 /** - * Keeps asking for the lock a peer holds, backing off from 1 s to 30 s. On Windows a dead - * holder's lock is released only after an OS-timed delay, so one refusal is not the answer. - * In memory only: it dies with the process or when cancelled. + * Keeps asking for the lock a peer holds, backing off from 1 s to 5 s. The lock frees as the + * holder exits, but nothing tells this process, so the cap is how long chats stay refused here + * after the owner quits. In memory only: it dies with the process or when cancelled. */ export function retryJournalOwnerLock(input: { stateDirectory: string From 5f4b1710e5aec7e63504a1cc8a074846248ac39d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:48:22 -0700 Subject: [PATCH 22/66] fix(native-chat): install before deciding whether a takeover must republish chats A list that landed on the refused startup after the lock was taken finished after the takeover had already checked, so nothing republished. The takeover now installs first, waits for any restore in flight, and restores only then; the restore that clears "cannot tell" pushes the frames itself, so a list that heals the inventory first reaches subscribers too. --- ...structured-agent-session-create-support.ts | 13 ++- ...tore-structured-agent-session-tabs-once.ts | 5 ++ ...ed-agent-session-journal-ownership.test.ts | 81 +++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 18f0544140ff..3bc97340b19e 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -303,17 +303,16 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR protected async restoreStructuredAgentSessionsAfterTakeover(): Promise { const refusedStartup = this.structuredAgentSessionStartupRestorePromise - await Promise.allSettled([refusedStartup, this.structuredAgentSessionTabRestorePromise]) + await refusedStartup?.catch(() => undefined) if (this.structuredAgentSessionStartupRestorePromise === refusedStartup) { this.structuredAgentSessionStartupRestorePromise = null } - if (!this.structuredAgentSessionInventoryUnverifiable) { - await this.prepareStructuredAgentSessionStartupRestoration() - return + await this.prepareStructuredAgentSessionStartupRestoration() + // Install first, then ask: a list that ran on the refused startup may only now have finished. + await this.structuredAgentSessionTabRestorePromise?.catch(() => undefined) + if (this.structuredAgentSessionInventoryUnverifiable) { + await this.restoreStructuredAgentSessionTabs() } - await this.restoreStructuredAgentSessionTabs() - // The restore publishes quietly; subscribers still hold the frames that said "cannot tell". - this.notifyMobileSessionTabSnapshots() } protected hasPersistedStructuredAgentSessionStore(): boolean { diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index 63e49e2ab4ec..8d29a0d360b9 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -86,9 +86,14 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu notify: false }) } + const wasUnverifiable = this.structuredAgentSessionInventoryUnverifiable // No host means no one can say which chats exist; with none on disk, empty is the answer. this.structuredAgentSessionInventoryUnverifiable = !host && this.hasPersistedStructuredAgentSessionStore() + // This restore published quietly; subscribers still hold the frames that said "cannot tell". + if (wasUnverifiable && !this.structuredAgentSessionInventoryUnverifiable) { + this.notifyMobileSessionTabSnapshots() + } } async publishStructuredAgentSessionTab(input: { diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index ce3fb29f48e0..b67d8d91c837 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -292,6 +292,16 @@ describe('startup and other non-chat work without a structured host', () => { }) }) + function heldOnce(mock: ReturnType): () => void { + let release!: () => void + const held = new Promise((resolve) => (release = resolve)) + mock.mockImplementationOnce(async () => { + await held + return new Set() + }) + return release + } + // The inventory must say "cannot tell", never "no chats": a client culls what an answer omits, // and the desktop then saves its chat tabs away. describe('the session-tabs inventory', () => { @@ -426,6 +436,77 @@ describe('startup and other non-chat work without a structured host', () => { expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') expect(reconciled).toHaveBeenCalledOnce() }) + + /** A startup whose host has a Claude chat once the takeover installs it. */ + function takeoverRuntime() { + const reconciled = vi.fn(async () => undefined) + const started = startupRuntime(async () => { + const host = await install() + vi.spyOn(host, 'listSessionTabs').mockReturnValue([ + { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' } + ]) + vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) + vi.spyOn(host, 'reconcileRestartLeases').mockImplementation(reconciled) + return host + }) + publishWorktreeFrame(started.runtime) + return { ...started, reconciled } + } + + const CLAUDE_CHAT = expect.objectContaining({ type: 'agent-session', sessionId: 'claude-1' }) + + it('pushes the chats when a list lands during the refused startup, after the takeover', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords, reconciled } = takeoverRuntime() + const releaseRefresh = heldOnce(refreshPtyRecords) + const pushed: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) + const startup = runtime.prepareStructuredAgentSessionStartupRestoration() + await vi.waitFor(() => expect(refreshPtyRecords).toHaveBeenCalledOnce()) + + await holder.kill() + holder = null + await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) + // Still on the refused startup's memo, so this list cannot tell. + const listed = listInventory(runtime) + releaseRefresh() + await startup + + expect((await listed)[0]).toMatchObject({ agentSessionsUnverifiable: true, tabs: [] }) + await vi.waitFor(() => expect(pushed.at(-1)?.tabs).toEqual([CLAUDE_CHAT]), { + timeout: 10_000 + }) + unsubscribe() + expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') + expect(reconciled).toHaveBeenCalledOnce() + }) + + it('pushes the chats when a list heals them during the takeover install', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords } = takeoverRuntime() + expect((await listInventory(runtime))[0]).toMatchObject({ agentSessionsUnverifiable: true }) + const releaseRefresh = heldOnce(refreshPtyRecords) + const pushed: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) + + await holder.kill() + holder = null + // The takeover's own startup restoration is refreshing PTYs, with its host installed. + await vi.waitFor(() => expect(refreshPtyRecords).toHaveBeenCalledTimes(2), { + timeout: 10_000 + }) + const listed = listInventory(runtime) + releaseRefresh() + + expect((await listed)[0]?.tabs).toEqual([CLAUDE_CHAT]) + await vi.waitFor(() => expect(pushed.at(-1)?.tabs).toEqual([CLAUDE_CHAT]), { + timeout: 10_000 + }) + unsubscribe() + expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') + }) }) it('lets a terminal resume command through while another process owns the chats', async () => { From 3a9f5cb03ccbd9e54d57a697096f795008baa568 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:48:28 -0700 Subject: [PATCH 23/66] fix(native-chat): no takeover lands a host after the runtime stop Quitting cancelled a refused claim's retry only at its end, so a retry firing during the stop's awaits took the lock and installed a host the stop never tore down, and the lock was then released under an open journal. The stop now cancels the retry first, keeping the refusal, and repeats its teardown while an install that began during it (a takeover already under way) is pending, so no journal connection outlives the lock. --- ...ed-agent-session-journal-ownership.test.ts | 69 ++++++++++++++++++- ...uctured-agent-session-journal-ownership.ts | 8 ++- .../structured-agent-session-runtime.ts | 35 +++++----- 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index b67d8d91c837..bc71392588dd 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -10,7 +10,10 @@ import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' -import { journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' +import { + JournalHostDatabase, + journalDatabasePath +} from '../native-chat/agent-session-journal/journal-host-database' import { JOURNAL_OWNER_LOCK_FILE } from '../native-chat/agent-session-journal/journal-owner-lock' import { holdJournalOwnerLockInChild, @@ -20,6 +23,7 @@ import { import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../native-chat/agent-session-journal/journal-open-failure' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import Database from '../sqlite/sync-database' +import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import { OrcaRuntimeService } from './orca-runtime' @@ -30,6 +34,7 @@ import { readRuntimeMetadata } from './runtime-metadata' import { OrcaRuntimeRpcServer } from './runtime-rpc' import { JOURNAL_OWNER_REFUSAL_MESSAGES, + onStructuredAgentSessionJournalOwned, releaseStructuredAgentSessionJournal, setJournalOwnerProcessKind, type JournalOwnerProcessKind @@ -509,6 +514,68 @@ describe('startup and other non-chat work without a structured host', () => { }) }) + describe('quitting while refused', () => { + function holdCatalogFlush(): () => void { + let release!: () => void + const held = new Promise((resolve) => (release = resolve)) + vi.spyOn(agentModelCatalogStore, 'flushPersistence').mockImplementationOnce(() => held) + return release + } + + it('does not take the lock once the stop has begun', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + await runtime.prepareStructuredAgentSessionStartupRestoration() + const owned = vi.fn() + stopAwaitingOwnership.push(onStructuredAgentSessionJournalOwned(owned)) + const releaseFlush = holdCatalogFlush() + const stopping = stopStructuredAgentSessionRuntime() + + await holder.kill() + holder = null + // Past the longest retry delay: a retry still scheduled would have taken the lock by now. + await new Promise((resolve) => setTimeout(resolve, 5_500)) + releaseFlush() + await stopping + + expect(owned).not.toHaveBeenCalled() + expect(getStructuredAgentSessionHost()).toBeNull() + expect(existsSync(journalDatabasePath(root))).toBe(false) + }) + + it('tears down a takeover install that lands during the stop', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords } = startupRuntime(async () => { + const host = await install() + vi.spyOn(host, 'reconcileRestartLeases').mockResolvedValue(undefined) + return host + }) + const releaseRefresh = heldOnce(refreshPtyRecords) + const startup = runtime.prepareStructuredAgentSessionStartupRestoration() + await vi.waitFor(() => expect(refreshPtyRecords).toHaveBeenCalledOnce()) + await holder.kill() + holder = null + // Taken before the quit: the takeover waits on the refused startup's PTY refresh. + await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) + const closed = vi.spyOn(JournalHostDatabase.prototype, 'close') + const releaseFlush = holdCatalogFlush() + const stopping = stopStructuredAgentSessionRuntime() + + releaseRefresh() + await startup + await vi.waitFor(() => expect(getStructuredAgentSessionHost()).not.toBeNull(), { + timeout: 10_000 + }) + releaseFlush() + await stopping + + expect(getStructuredAgentSessionHost()).toBeNull() + expect(closed).toHaveBeenCalledOnce() + }) + }) + it('lets a terminal resume command through while another process owns the chats', async () => { holder = await holdJournalOwnerLockInChild(root) vi.spyOn(console, 'warn').mockImplementation(() => undefined) diff --git a/src/main/runtime/structured-agent-session-journal-ownership.ts b/src/main/runtime/structured-agent-session-journal-ownership.ts index a346058a02ad..c53ca667e51d 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.ts @@ -136,9 +136,15 @@ export function onStructuredAgentSessionJournalOwned(listener: () => void): () = return () => ownedListeners.delete(listener) } +/** First, when stopping: a takeover landing during the teardown would install a host after it. + * The refusal stands, so a request in that window is still refused rather than claiming. */ +export function stopRetryingStructuredAgentSessionJournalClaim(): void { + refused?.retry.cancel() +} + /** Last, after the journal database is closed. Also stops a refused claim's retry. */ export function releaseStructuredAgentSessionJournal(): void { - refused?.retry.cancel() + stopRetryingStructuredAgentSessionJournalClaim() refused = null const lock = held held = null diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index cb9c627dc323..d94badbd72e4 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -47,6 +47,7 @@ import { claimStructuredAgentSessionJournal, recordStructuredAgentSessionHostInstallRefusal, releaseStructuredAgentSessionJournal, + stopRetryingStructuredAgentSessionJournalClaim, structuredAgentSessionJournalOwnerRefusal } from './structured-agent-session-journal-ownership' import { agentSessionStorePath } from './agent-session-record-store-file' @@ -169,26 +170,28 @@ export async function waitForStructuredAgentSessionRecovery(): Promise { export async function stopStructuredAgentSessionRuntime(options?: { trigger?: AgentSessionResumeTrigger }): Promise { + stopRetryingStructuredAgentSessionJournalClaim() const trigger = options?.trigger ?? structuredAgentSessionTeardownTrigger() - const pending = installing - installing = null - setStructuredAgentSessionHost(null) + const failures: unknown[] = [] + // An install that began while this stop awaited (a takeover already under way) is torn down + // too: no journal connection may outlive the lock. const outstanding = [...pendingTeardown] pendingTeardown.clear() - const installed = pending ? await pending.catch(() => null) : null - if (installed) { - outstanding.push(installed) - } - const failures: unknown[] = [] - for (const runtime of outstanding) { - try { - await tearDownRuntime(runtime, trigger) - } catch (error) { - pendingTeardown.add(runtime) - failures.push(error) + do { + const pending = installing + installing = null + setStructuredAgentSessionHost(null) + const installed = await pending?.catch(() => null) + for (const runtime of [...outstanding.splice(0), ...(installed ? [installed] : [])]) { + try { + await tearDownRuntime(runtime, trigger) + } catch (error) { + pendingTeardown.add(runtime) + failures.push(error) + } } - } - await agentModelCatalogStore.flushPersistence() + await agentModelCatalogStore.flushPersistence() + } while (installing) if (failures.length === 0 && pendingTeardown.size === 0) { releaseStructuredAgentSessionJournal() } From 4a40671844b12ec682a34f303f41f982521609f5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 05:58:44 -0700 Subject: [PATCH 24/66] fix(native-chat): show a thrown refusal in its own words, not its code A refusal the host throws reaches the client as an RPC error whose message is the bare code; its reason and facts ride only in the error's data, which no client read. The chat pane's status line therefore printed agent_session_journal_unreadable, a send took the bare "not sent" path, and other writes said the outcome was unconfirmed. One shared reader, agentSessionThrownRefusal, now reads the refusal from the error data. A failed history read shows the refusal's read-history words, a send keeps the refusal behind its Retry exactly as a returned refusal does, and the other writes (desktop and phone) name the refusal instead of doubting the outcome. The phone's read failure goes through the same reader. --- .../mobile-structured-agent-session-rpc.ts | 44 ++++++++++- ...tured-agent-session-thrown-refusal.test.ts | 66 ++++++++++++++++ .../use-mobile-structured-agent-state.ts | 9 ++- src/main/runtime/rpc/errors.test.ts | 30 ++++++++ ...tructured-agent-session-outbox-dispatch.ts | 30 ++++++-- ...tured-agent-session-read-transport.test.ts | 58 +++++++++++++- ...structured-agent-session-read-transport.ts | 16 +++- .../use-structured-agent-session-mutate.ts | 6 +- ...nt-session-outbox-rejection-cause.test.tsx | 49 ++++++++++++ ...structured-agent-session-launch-options.ts | 7 +- .../agent-session-write-failure.test.ts | 47 +++++++++++ src/shared/agent-session-write-failure.ts | 25 +++++- ...ructured-agent-session-send-disposition.ts | 77 +++++++++++-------- 13 files changed, 408 insertions(+), 56 deletions(-) create mode 100644 mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts index bca14e293f75..20d37f3ca352 100644 --- a/mobile/src/session/mobile-structured-agent-session-rpc.ts +++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts @@ -16,6 +16,7 @@ import { } from '../../../src/shared/agent-session-refusal-notice' import { agentSessionRpcErrorFailure, + agentSessionThrownRefusal, agentSessionWriteKindForMethod, type AgentSessionWriteKind } from '../../../src/shared/agent-session-write-failure' @@ -51,12 +52,43 @@ 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 data = + failure instanceof AgentSessionRpcResponseError + ? failure.data + : typeof failure === 'object' && + failure !== null && + 'error' in failure && + typeof failure.error === 'object' && + failure.error !== null && + 'data' in failure.error + ? failure.error.data + : undefined + const refusal = agentSessionThrownRefusal(data) + if (refusal) { + return agentSessionWriteNoticeEnglish(agentSessionWriteNoticeParts(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 +102,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 +222,9 @@ export async function requestStructuredAgentSessionMutation(args: { } } catch (error) { const answered = - error instanceof AgentSessionRpcResponseError ? agentSessionRpcErrorFailure(error.code) : null + error instanceof AgentSessionRpcResponseError + ? agentSessionRpcErrorFailure(error.code, error.data) + : 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..5391cdd322ca --- /dev/null +++ b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts @@ -0,0 +1,66 @@ +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_OWNER_REFUSAL = { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalOwnedElsewhere', processKind: 'packaged' } + } + } +} + +function refusingClient(): RpcClient { + const sendRequest = async () => ({ id: 'req-1', ok: false, error: THROWN_OWNER_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 = + "Chats are open in another Orca using this profile. This chat's history couldn't be loaded. Quit that Orca to use chats here." + // The stream's error frame, as the RPC client hands it over. + expect( + agentSessionReadFailureText({ + type: 'error', + message: THROWN_OWNER_REFUSAL.message, + error: THROWN_OWNER_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: + "Chats are open in another Orca using this profile. The agent wasn't stopped. Quit that Orca to use chats here." + }) + }) +}) 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/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 624557c1f7f4..fa498016dab6 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -20,6 +20,7 @@ import { agentSessionRefusalError, refuseUnclassified } from '../../../shared/agent-session-wire-refusals' +import { agentSessionThrownRefusal } from '../../../shared/agent-session-write-failure' class LineageError extends Error { code = 'LINEAGE_PARENT_NOT_FOUND' @@ -356,6 +357,35 @@ describe('thrown agent-session refusals', () => { }) }) + // The shape every client test of a thrown refusal feeds in, pinned to what this produces. + it("sends the owner refusal's reason and process kind where a client reads them", () => { + const response = mapRuntimeError( + 'req_1', + meta, + agentSessionRefusalError( + 'agent_session_journal_unreadable', + { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' }, + 'Chats are open in another Orca window using this profile.' + ) + ) + const wire = JSON.parse(JSON.stringify(response.error)) + expect(wire).toEqual({ + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } + } + } + }) + expect(agentSessionThrownRefusal(wire.data)).toEqual({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } + }) + }) + it('exposes no code property another passthrough could claim', () => { expect( 'code' in agentSessionRefusalError('agent_session_conflict', { reason: 'claimConflicted' }) diff --git a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts index 9f91b9f9b3b1..4eb22f1957f5 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-outbox-dispatch.ts @@ -2,12 +2,15 @@ import type { AgentSessionMutationResult, AgentSessionSendResult } from '../../../../shared/agent-session-wire' +import { agentSessionThrownRefusal } from '../../../../shared/agent-session-write-failure' import { disposeStructuredAgentSessionSendFailure, + disposeStructuredAgentSessionSendRefusal, disposeStructuredAgentSessionSendResult, type StructuredAgentSessionSendDisposition } from '../../../../shared/structured-agent-session-send-disposition' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client' import { stageStructuredAgentSessionOutboxEntryForSend, @@ -126,14 +129,27 @@ export function dispatchStructuredAgentSessionOutboxEntry(args: { if (args.dispatchGenerationRef.current !== args.dispatchGeneration) { return false } + const input = { + entries: args.outboxRef.current, + entry: args.next, + blockedClientMessageId: args.blockedIdRef.current + } + const refusal = + caught instanceof RuntimeRpcCallError + ? agentSessionThrownRefusal(caught.response.error.data) + : undefined args.applyDisposition( - disposeStructuredAgentSessionSendFailure({ - entries: args.outboxRef.current, - entry: args.next, - blockedClientMessageId: args.blockedIdRef.current, - cause: caught, - isDeliveryUnknown: isDesktopDeliveryUnknown - }) + refusal + ? disposeStructuredAgentSessionSendRefusal({ + ...input, + refusal, + createOperationId: args.createOperationId + }) + : disposeStructuredAgentSessionSendFailure({ + ...input, + cause: caught, + isDeliveryUnknown: isDesktopDeliveryUnknown + }) ) return false } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index 0305b93cb6d4..55895ffd45da 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -15,6 +15,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ subscribeStructuredAgentSession: mocks.subscribe })) +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { startStructuredAgentSessionReadTransport } from './structured-agent-session-read-transport' type SubscribeAttempt = { @@ -311,6 +312,61 @@ describe('structured agent-session read transport unattached refusals', () => { } }) + // As `mapRuntimeError` sends a thrown refusal (pinned in `rpc/errors.test.ts`): its message is the + // bare code, and its reason rides in data. + function thrownRefusal(details: Record) { + return { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { refusal: { code: 'agent_session_journal_unreadable', details } } + } + } + const OWNED_BY_DEV_ORCA = + "Chats are open in another Orca using this profile. This chat's history couldn't be loaded. Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH)." + + it("shows a thrown refusal's own words from the stream, never its code", async () => { + vi.useFakeTimers() + try { + const applyError = vi.fn() + const transport = startWithHydration(async () => undefined, applyError) + await flushPromises() + + attempts[0].onError( + thrownRefusal({ reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' }) + ) + attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) + await flushPromises() + expect(applyError).toHaveBeenCalledExactlyOnceWith(OWNED_BY_DEV_ORCA) + + await vi.advanceTimersByTimeAsync(750) + attempts[1].onError(thrownRefusal({ reason: 'journalCorrupt' })) + expect(applyError).toHaveBeenLastCalledWith('Unable to load this chat.') + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + + it("shows a thrown refusal's own words when the history read rejects with it", async () => { + vi.useFakeTimers() + try { + const applyError = vi.fn() + const refused = new RuntimeRpcCallError({ + id: 'req-1', + ok: false, + error: thrownRefusal({ reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' }) + }) + const transport = startWithHydration(async () => { + throw refused + }, applyError) + await flushPromises() + expect(applyError).toHaveBeenCalledExactlyOnceWith(OWNED_BY_DEV_ORCA) + transport.dispose() + } finally { + vi.useRealTimers() + } + }) + it('re-opens after a failed open and leaves the error once the conversation reads (P2-04)', async () => { vi.useFakeTimers() try { @@ -318,7 +374,7 @@ describe('structured agent-session read transport unattached refusals', () => { const applyEvent = vi.fn() const transport = startWithHydration(async () => undefined, applyError, applyEvent) await flushPromises() - attempts[0].onError({ code: 'agent_session_journal_unreadable', message: 'disk full' }) + attempts[0].onError({ code: 'runtime_error', message: 'disk full' }) attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) await flushPromises() expect(applyError).toHaveBeenCalledExactlyOnceWith('disk full') diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts index ed8423fd079b..aecc2d8e8130 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.ts @@ -5,11 +5,25 @@ import { AGENT_SESSION_UNATTACHED_READ_GRACE_MS, isUnattachedAgentSessionReadRefusal } from '../../../../shared/structured-agent-session-read-refusal' +import { agentSessionThrownRefusal } from '../../../../shared/agent-session-write-failure' import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { subscribeStructuredAgentSession } from '@/runtime/structured-agent-session-client' +import { agentSessionWriteFailureText } from './agent-session-write-notice-text' -// A stream hands its failure over as the raw `{ code, message }` payload; `String()` of that is `[object Object]`. +// A stream hands its failure over as the raw `{ code, message, data }` payload; `String()` of that is `[object Object]`. function readFailureText(error: unknown): string { + // A thrown refusal's message is its bare code; its words come from the refusal in `data`. + const refusal = agentSessionThrownRefusal( + error instanceof RuntimeRpcCallError + ? error.response.error.data + : typeof error === 'object' && error !== null && 'data' in error + ? error.data + : undefined + ) + if (refusal) { + return agentSessionWriteFailureText(refusal, 'read-history') + } if (error instanceof Error) { return error.message || 'Something went wrong.' } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts index 328d76d05344..3a1d742124dd 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-mutate.ts @@ -98,9 +98,9 @@ export function useStructuredAgentSessionMutate(args: { ? { kind: 'not-done', notice: agentSessionWriteFailureText( - agentSessionRpcErrorFailure( - error instanceof RuntimeRpcCallError ? error.code : undefined - ), + error instanceof RuntimeRpcCallError + ? agentSessionRpcErrorFailure(error.code, error.response.error.data) + : agentSessionRpcErrorFailure(undefined), writeKind(fingerprintMethod, fields) ) } diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index a8b9f01b20b9..f2f6fce9937f 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -14,6 +14,7 @@ vi.mock('@/runtime/structured-agent-session-client', () => ({ })) import { setLocalRuntimeCapabilitiesForTests } from '@/runtime/local-runtime-capabilities' +import { RuntimeRpcCallError } from '@/runtime/runtime-rpc-result' import { useStructuredAgentSessionOutbox } from './use-structured-agent-session-outbox' import { agentSessionWriteNoticeEnglish } from '../../../../shared/agent-session-refusal-notice' import { structuredAgentSessionAttemptFailureParts } from '../../../../shared/structured-agent-session-send-disposition' @@ -405,3 +406,51 @@ describe('a send refused while its agent restarted', () => { expect(result.current.blockedClientMessageId).toBeNull() }) }) + +describe('a send the host refused by throwing', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it("keeps the refusal on the message, with the refusal's words and not a bare failure", async () => { + // As `mapRuntimeError` sends a thrown refusal (pinned in `rpc/errors.test.ts`). + mocks.call.mockRejectedValue( + new RuntimeRpcCallError({ + id: 'req-1', + ok: false, + error: { + code: 'runtime_error', + message: 'agent_session_journal_unreadable', + data: { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalOwnedElsewhere', processKind: 'packaged' } + } + } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + + await waitFor(() => + expect(result.current.outbox[0]?.lastFailure).toEqual({ + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalOwnedElsewhere', processKind: 'packaged' } + }) + ) + expect(shownFailure(result.current.outbox[0])).toBe( + 'Chats are open in another Orca using this profile. Your message was not sent. Quit that Orca to use chats here.' + ) + }) +}) diff --git a/src/renderer/src/lib/structured-agent-session-launch-options.ts b/src/renderer/src/lib/structured-agent-session-launch-options.ts index 091e7543f88a..d27792d7c954 100644 --- a/src/renderer/src/lib/structured-agent-session-launch-options.ts +++ b/src/renderer/src/lib/structured-agent-session-launch-options.ts @@ -110,9 +110,10 @@ async function setLaunchOption( } catch (error) { return { kind: 'refused', - failure: agentSessionRpcErrorFailure( - error instanceof RuntimeRpcCallError ? error.code : undefined - ) + failure: + error instanceof RuntimeRpcCallError + ? agentSessionRpcErrorFailure(error.code, error.response.error.data) + : agentSessionRpcErrorFailure(undefined) } } } diff --git a/src/shared/agent-session-write-failure.test.ts b/src/shared/agent-session-write-failure.test.ts index 83d8faea7901..9a2d8ae76f9c 100644 --- a/src/shared/agent-session-write-failure.test.ts +++ b/src/shared/agent-session-write-failure.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { agentSessionRefusalFailure, + agentSessionRpcErrorFailure, + agentSessionThrownRefusal, parseAgentSessionWriteFailure } from './agent-session-write-failure' @@ -122,3 +124,48 @@ describe('agentSessionRefusalFailure', () => { }) }) }) + +describe('a refusal the host threw', () => { + // The RPC error's data `mapRuntimeError` sends for it (wire code `runtime_error`), as it + // reaches a client over JSON. + const data = saved({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } + } + }) + + it('is read from the error data, reason and process kind kept', () => { + const refusal = { + kind: 'refused', + code: 'agent_session_journal_unreadable', + details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } + } + expect(agentSessionThrownRefusal(data)).toEqual(refusal) + // Not "Orca couldn't confirm what happened": the host refused it before running it. + expect(agentSessionRpcErrorFailure('runtime_error', data)).toEqual(refusal) + }) + + it("degrades a reason or kind another build added to the code's own words", () => { + const newer = { + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalFromTheFuture', processKind: 'phone' } + } + } + expect(agentSessionThrownRefusal(newer)).toEqual({ + kind: 'refused', + code: 'agent_session_journal_unreadable' + }) + }) + + it('finds none in an error that carries no refusal', () => { + expect(agentSessionThrownRefusal(undefined)).toBeUndefined() + expect( + agentSessionThrownRefusal({ refusal: { code: 'agent_session_from_the_future' } }) + ).toBeUndefined() + expect(agentSessionRpcErrorFailure('runtime_error', { nextSteps: [] })).toEqual({ + kind: 'unconfirmed' + }) + }) +}) diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts index f178e165008c..43205f5163f0 100644 --- a/src/shared/agent-session-write-failure.ts +++ b/src/shared/agent-session-write-failure.ts @@ -10,6 +10,7 @@ import { import type { AgentSessionRewindReason } from './agent-session-rewind' import { isAgentSessionWireRefusalCode, + readAgentSessionRefusalReference, type AgentSessionOwnerVerdict, type AgentSessionWireRefusal, type AgentSessionWireRefusalCode @@ -128,10 +129,26 @@ export function agentSessionRefusalFailure( : { kind: 'refused', code: refusal.code } } -/** A request that threw, from the RPC error code the host answered with (undefined when none came - * back). Only a host that turned it away before running the method proves the write did not - * happen. */ -export function agentSessionRpcErrorFailure(code: string | undefined): AgentSessionWriteFailure { +/** The refusal a thrown request carries in its RPC error's `data` (`main/runtime/rpc/errors.ts`): + * its wire message is the bare code, so this is the only place its reason survives. */ +export function agentSessionThrownRefusal(data: unknown): AgentSessionWriteRefusal | undefined { + const reference = readAgentSessionRefusalReference( + typeof data === 'object' && data !== null && 'refusal' in data ? data.refusal : undefined + ) + return reference ? agentSessionRefusalFailure(reference) : undefined +} + +/** A request that threw, from the RPC error code and data the host answered with (undefined when + * none came back). Only a host that turned it away before running the method proves the write + * did not happen. */ +export function agentSessionRpcErrorFailure( + code: string | undefined, + data?: unknown +): AgentSessionWriteFailure { + const refusal = agentSessionThrownRefusal(data) + if (refusal) { + return refusal + } if (code === 'method_not_found' || code === 'method_not_supported') { return { kind: 'refused', code: 'structured_agent_session_unsupported' } } diff --git a/src/shared/structured-agent-session-send-disposition.ts b/src/shared/structured-agent-session-send-disposition.ts index 7ffce942b6e6..9f94980c638f 100644 --- a/src/shared/structured-agent-session-send-disposition.ts +++ b/src/shared/structured-agent-session-send-disposition.ts @@ -15,7 +15,10 @@ import { agentSessionWriteNotDoneParts } from './agent-session-refusal-notice' import type { AgentSessionWriteNoticePart } from './agent-session-write-notice-copy' -import { agentSessionRefusalFailure } from './agent-session-write-failure' +import { + agentSessionRefusalFailure, + type AgentSessionWriteRefusal +} from './agent-session-write-failure' import { classifyDispatchRejection } from './structured-agent-session-dispatch-rejection' import { classifyStructuredAgentSessionSendFailure, @@ -152,6 +155,44 @@ export function structuredAgentSessionAttemptFailureParts( : agentSessionWriteNoticeParts(failure, 'send') } +/** A send the host refused, whether it returned the refusal or threw it. */ +export function disposeStructuredAgentSessionSendRefusal( + input: SendDispositionInput & { + refusal: AgentSessionWriteRefusal + createOperationId: () => string + } +): StructuredAgentSessionSendDisposition { + const refusedIndex = input.entries.findIndex( + (candidate) => candidate.clientMessageId === input.entry.clientMessageId + ) + const entries = input.entries.map((candidate) => + candidate.clientMessageId === input.entry.clientMessageId + ? withLastFailure( + requeueStructuredAgentSessionSendRefusal( + candidate, + input.refusal, + input.createOperationId, + input.entry.lastAttemptAt !== null + ), + input.refusal + ) + : candidate + ) + const refused = entries[refusedIndex] + return { + entries, + error: null, + // Read back by index rather than from the input: a refusal can rotate the id, and the + // refused entry is not always the head now that an admitted one no longer holds the queue. + // A rejected one holds nothing: it can no longer land, and it keeps its own Retry. + blockedClientMessageId: + !refused || refused.state === 'rejected' + ? input.blockedClientMessageId + : refused.clientMessageId, + retryWithFreshClientMessageId: null + } +} + export function disposeStructuredAgentSessionSendResult( input: SendDispositionInput & { result: AgentSessionMutationResult @@ -160,36 +201,10 @@ export function disposeStructuredAgentSessionSendResult( ): StructuredAgentSessionSendDisposition { const result = input.result if (!result.ok) { - const refusedIndex = input.entries.findIndex( - (candidate) => candidate.clientMessageId === input.entry.clientMessageId - ) - const refusal = agentSessionRefusalFailure(result.refusal) - const entries = input.entries.map((candidate) => - candidate.clientMessageId === input.entry.clientMessageId - ? withLastFailure( - requeueStructuredAgentSessionSendRefusal( - candidate, - refusal, - input.createOperationId, - input.entry.lastAttemptAt !== null - ), - refusal - ) - : candidate - ) - const refused = entries[refusedIndex] - return { - entries, - error: null, - // Read back by index rather than from the input: a refusal can rotate the id, and the - // refused entry is not always the head now that an admitted one no longer holds the queue. - // A rejected one holds nothing: it can no longer land, and it keeps its own Retry. - blockedClientMessageId: - !refused || refused.state === 'rejected' - ? input.blockedClientMessageId - : refused.clientMessageId, - retryWithFreshClientMessageId: null - } + return disposeStructuredAgentSessionSendRefusal({ + ...input, + refusal: agentSessionRefusalFailure(result.refusal) + }) } const submission = result.value.submission if (refusedRedelivery(input.entry, submission)) { From e8228fc89ce290b832e10d681f04f63b1274adc6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 06:03:32 -0700 Subject: [PATCH 25/66] fix(native-chat): log a failed journal open once per distinct failure Every chat request retries a journal open that failed, which is intended, but each retry also logged the failure with its full stack: a junk database file logged the same "file is not a database" error 189 times in a minute. The open now logs a failure only when its code and message differ from the last one logged, and forgets it once an open succeeds. The retry is unchanged. The open moves to its own module beside the runtime, which had no room left. --- .../structured-agent-session-journal-open.ts | 49 +++++++++++++++++++ ...ed-agent-session-journal-ownership.test.ts | 38 ++++++++++++++ .../structured-agent-session-runtime.ts | 32 +----------- 3 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-journal-open.ts diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts new file mode 100644 index 000000000000..754af523ace4 --- /dev/null +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -0,0 +1,49 @@ +// Opening the chat journal database under this process's owner lock, for the host install. + +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' +import { + claimStructuredAgentSessionJournal, + recordStructuredAgentSessionHostInstallRefusal, + structuredAgentSessionJournalOwnerRefusal +} from './structured-agent-session-journal-ownership' + +// Every chat request retries a failed open, so each distinct failure is logged once, with its stack. +let lastLoggedOpenFailure: string | null = null + +function logOpenFailureOnce(error: unknown): void { + const failure = + error instanceof Error + ? `${'code' in error ? String(error.code) : ''}:${error.message}` + : String(error) + if (failure === lastLoggedOpenFailure) { + return + } + lastLoggedOpenFailure = failure + console.warn('[structured-agent-session] opening the chat journal database failed', error) +} + +/** The journal database, opened only under this process's owner lock. A refusal is recorded for + * the gate and thrown to the caller; the next install tries again. A lock file that cannot be + * opened at all refuses like the database it guards. */ +export function openOwnedJournalDatabase(stateDirectory: string): JournalHostDatabase { + try { + const lock = claimStructuredAgentSessionJournal(stateDirectory) + if (lock) { + const opened = JournalHostDatabase.open(lock) + recordStructuredAgentSessionHostInstallRefusal(null) + lastLoggedOpenFailure = null + return opened + } + } catch (error) { + logOpenFailureOnce(error) + // Nothing is renamed, deleted or rebuilt: the file is left exactly as it is. + const refusal = journalOpenRefusalError(error) + recordStructuredAgentSessionHostInstallRefusal(refusal) + throw refusal + } + throw ( + structuredAgentSessionJournalOwnerRefusal() ?? + new Error('the chat journal owner lock was refused') + ) +} diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index bc71392588dd..18d172e2c5fa 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -217,6 +217,44 @@ describe('the owner, when its journal will not open', () => { }) }) +describe('logging a journal that will not open', () => { + const OPEN_FAILED = '[structured-agent-session] opening the chat journal database failed' + + async function writeJunkJournal(): Promise { + const path = journalDatabasePath(root) + await rm(`${path}-wal`, { force: true }) + await rm(`${path}-shm`, { force: true }) + await writeFile(path, 'not a database '.repeat(512)) + } + + // Every chat request retries the open; the same failure each time is one log, not one per request. + it('logs a repeated failure once, with its stack, and again after an open succeeds', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const openFailureLogs = () => warn.mock.calls.filter(([message]) => message === OPEN_FAILED) + await install() + await stopStructuredAgentSessionRuntime() + await writeJunkJournal() + + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + } + expect(openFailureLogs()).toHaveLength(1) + expect(openFailureLogs()[0]?.[1]).toBeInstanceOf(Error) + expect(openFailureLogs()[0]?.[1]).toHaveProperty('stack', expect.stringContaining('\n')) + + await unlink(journalDatabasePath(root)) + await install() + await stopStructuredAgentSessionRuntime() + await writeJunkJournal() + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + expect(openFailureLogs()).toHaveLength(2) + }) +}) + // A refused host is a no-host state for startup: the app restores terminals and tabs as usual, // and only structured requests are refused. describe('startup and other non-chat work without a structured host', () => { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index d94badbd72e4..756ea22d0f57 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -41,15 +41,11 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' -import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { - claimStructuredAgentSessionJournal, - recordStructuredAgentSessionHostInstallRefusal, releaseStructuredAgentSessionJournal, - stopRetryingStructuredAgentSessionJournalClaim, - structuredAgentSessionJournalOwnerRefusal + stopRetryingStructuredAgentSessionJournalClaim } from './structured-agent-session-journal-ownership' +import { openOwnedJournalDatabase } from './structured-agent-session-journal-open' import { agentSessionStorePath } from './agent-session-record-store-file' import { stopOrphanAgentSessionChildren } from './agent-session-orphan-child-reaper' import { @@ -349,27 +345,3 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise Date: Mon, 28 Sep 2026 10:47:08 -0700 Subject: [PATCH 26/66] fix(native-chat): restore lists a chat from its per-chat file and copies it on first use Startup restore opened every restored chat, and that open copied the chat's per-chat file into the host database, so the first boot after an upgrade paid the whole one-time copy before the chat list appeared. A restore open now reads a chat that is still in its per-chat file straight from that file, read-only, with the importer's own reader, and closes the file before moving on. That read drives the listing, the status row and the restart offer, as it did when every chat had its own file. The copy becomes owed work on the chat's write queue: it runs before the chat's first write, and a reader that reaches the chat awaits it. A chat the host already holds, or that was copied before, still opens through the import and its reimport rules, and so does a file whose read needs a repair written. --- .../agent-session-journal/journal-open.ts | 23 +- .../journal-per-session-import.ts | 48 +++ .../journal-store-collaborators.ts | 4 + .../journal-store-contracts.ts | 2 + .../journal-store-restore.ts | 24 +- .../agent-session-journal/journal-store.ts | 18 +- .../journal-write-queue.ts | 21 +- .../agent-session-journal-recovery.ts | 4 +- ...red-agent-session-conversation-lifetime.ts | 9 + ...uctured-agent-session-conversation-open.ts | 9 +- .../structured-agent-session-read-restore.ts | 4 +- ...ent-session-restore-without-import.test.ts | 378 ++++++++++++++++++ 12 files changed, 529 insertions(+), 15 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts 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 77c9d58444ce..f313782581ed 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -47,9 +47,24 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal if (!pointer) { return null } - const { epoch } = pointer - const state = createJournalReducerState(sessionId, epoch) - const repairedFrom = pendingJournalRepairSequence(db, sessionId, epoch) + return foldJournalRows({ + sessionId, + pointer, + repairedFrom: pendingJournalRepairSequence(db, sessionId, pointer.epoch), + rows: iterateJournalEpochRows(db, pointer) + }) +} + +/** Folds one epoch's stored rows, in sequence order, wherever they are stored. */ +export function foldJournalRows(input: { + sessionId: string + pointer: JournalBlockPointer + /** The sequence a pending repair on this epoch left free. */ + repairedFrom: number | null + rows: Iterable<{ seq: number; rowJson: string }> +}): JournalLoad { + const { pointer, repairedFrom } = input + const state = createJournalReducerState(input.sessionId, pointer.epoch) let expectedSequence = FIRST_JOURNAL_SEQUENCE let gapSequence: number | undefined let unanchoredSequence: number | undefined @@ -60,7 +75,7 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal let latched = false let truncateFrom: number | undefined - for (const entry of iterateJournalEpochRows(db, pointer)) { + for (const entry of input.rows) { const parsed = parseJournalRow(entry.rowJson) if (!parsed.ok) { truncateFrom = entry.seq 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 index b16c5558c259..1df64240a433 100644 --- 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 @@ -23,9 +23,12 @@ import type { AgentSessionJournalIdentity } from '../../../shared/agent-session- import Database from '../../sqlite/sync-database' import type { SqliteRow } from '../../sqlite/sqlite-statement' import type { JournalHostDatabase } from './journal-host-database' +import { foldJournalRows, type JournalLoad } from './journal-open' import { legacyJournalDatabaseFile } from './journal-paths' +import { pendingJournalRepairSequence } from './journal-repair-marker' import { planPerSessionImport, + readPerSessionImportMarker, reimportedJournalRows, writePerSessionImportMarker, type PerSessionImportPlan, @@ -142,6 +145,51 @@ async function importOnce(input: ImportInput): Promise +): JournalLoad | null { + const { sessionId } = input.identity + const db = input.database.db + const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) + if ( + readJournalSessionPointer(db, sessionId) || + 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 = foldJournalRows({ + sessionId, + pointer: { epoch: legacy.epoch, block: -1 }, + repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs') + ? pendingJournalRepairSequence(source, sessionId, legacy.epoch) + : null, + rows: legacyRows(source, sessionId, legacy.epoch) + }) + return loaded.corrupt || loaded.readOnly || loaded.truncateFrom !== undefined ? null : loaded + } finally { + source.close() + } +} + +function* legacyRows(source: Database.Database, sessionId: string, epoch: string) { + for (const batch of legacyRowBatches(source, sessionId, epoch, IMPORT_BATCH_ROWS)) { + yield* batch.rows + } +} + /** A plain read-only connection: it sees committed WAL frames without checkpointing them. */ function openLegacySource(path: string): Database.Database { const source = new Database(path, { readonly: true, fileMustExist: true }) 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 9b1fbe5b473c..0e8ad5c5deaf 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 @@ -25,6 +25,10 @@ export type JournalStoreHost = { now: () => number mintEpoch: () => string serialize: (run: () => Promise) => Promise + /** Leave a chat still in its per-chat file uncopied until its first use. */ + deferPerSessionImport: boolean + /** Work the chat's next write waits for. */ + owe: (work: () => Promise) => void database: () => JournalHostDatabase state: () => JournalReducerState block: () => number 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 48d4b280e081..f93aa95c4bba 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 @@ -17,6 +17,8 @@ export type AgentSessionJournalOptions = { database: JournalHostDatabase now?: () => number mintEpoch?: () => string + /** A restore's open: see `AgentSessionJournal.whenImported`. */ + deferPerSessionImport?: boolean } export type JournalReadSince = diff --git a/src/main/native-chat/agent-session-journal/journal-store-restore.ts b/src/main/native-chat/agent-session-journal/journal-store-restore.ts index 7d802fad7b11..8c3a69cfdd07 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 @@ -11,22 +11,36 @@ import { replayJournal } from './journal-open' import type { JournalStoreHost } from './journal-store-collaborators' import { openJournalStoreState } from './journal-store-open' import { deleteJournalRepairedSuffix } from './journal-repair-marker' -import { importPerSessionJournal } from './journal-per-session-import' +import { importPerSessionJournal, previewPerSessionJournal } from './journal-per-session-import' export async function restoreJournalStore( host: JournalStoreHost, collaborators: { epochController: JournalEpochController } ): Promise { - // A per-chat file left by an earlier build is this chat's newest history: copied in first. - await importPerSessionJournal({ + const source = { database: host.database(), identity: host.identity, legacyDirectory: host.legacyDirectory - }) + } + // A restore reads a chat still in its per-chat file from there, and copies it before its first use. + const preview = host.deferPerSessionImport ? 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({ legacyDirectory: host.legacyDirectory, replay: () => { - const loaded = replayJournal(host.database().db, host.identity.sessionId) + const loaded = preview ?? replayJournal(host.database().db, host.identity.sessionId) host.setOpenedCorrupt(loaded?.corrupt ?? false) return loaded }, 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 047c7f05549e..bd4204ceb101 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -107,6 +107,8 @@ export class AgentSessionJournal { now: this.now, mintEpoch: this.mintEpoch, serialize: (run) => this.queue.serialize(run), + deferPerSessionImport: options.deferPerSessionImport === true, + owe: (work) => this.queue.owe(work), database: () => this.database, state: () => this.state, block: () => this.block, @@ -186,9 +188,23 @@ 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 + } + /** Saves the settled listing status at the fold's position, after the rows it describes. */ saveListingStatus(projection: StructuredAgentSessionStatusProjection): void { - if (!this.readOnly) { + // Not yet copied: no host row to annotate, and saving must not force the copy. + if (!this.readOnly && !this.queue.owing) { this.listingStatus.save(this.cursor(), projection, this.state.lastActivityAt) } } diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.ts index 518e5c3737a1..13fd9e4b96c2 100644 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.ts +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.ts @@ -10,6 +10,8 @@ import { AgentSessionJournalError } from './journal-write-guards' 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) {} @@ -29,13 +31,30 @@ export class JournalWriteQueue { 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 { - const started = this.writes.then(run) + // 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-wire/agent-session-journal-recovery.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.ts index 1568a6984dea..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 @@ -47,10 +47,12 @@ export async function openAgentSessionJournalWithRecovery(input: { fence: number /** Resolve directly to a transcript instead of discovering it by session id. */ historyFilePath?: string | null + deferPerSessionImport?: boolean }): Promise { const journal = await openAgentSessionJournal({ identity: input.identity, - database: input.database + database: input.database, + deferPerSessionImport: input.deferPerSessionImport }) if (!journal.needsRebuild) { return { journal, recovery: null } 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 e81eb9ae9763..5e8006506082 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 @@ -98,6 +98,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 open.journal.whenImported() + } return open } const record = deps().store.getRecord(sessionId) @@ -122,6 +127,10 @@ export function createStructuredAgentSessionConversationLifetime(host: { reason: 'recordMissing' }) } + // Restore may have opened it while this waited. + if (session.journal.importPending) { + await session.journal.whenImported() + } 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 a8eb35c90fbf..4480cb56311b 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 @@ -42,7 +42,11 @@ export type StructuredAgentSessionConversationOpenDeps = { /** 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 @@ -91,7 +95,8 @@ export async function openStructuredAgentSessionConversationJournal( identity, 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-read-restore.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts index 16adc64d4531..cf80396d8204 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.ts @@ -36,5 +36,7 @@ export async function restoreStructuredAgentSessionRead( return null } } - return openStructuredAgentSessionConversationJournal(deps, record) + return openStructuredAgentSessionConversationJournal(deps, record, { + deferPerSessionImport: true + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts new file mode 100644 index 000000000000..e6bfa04dcf0a --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -0,0 +1,378 @@ +// Startup restore lists a chat that is still in its per-chat file from a read-only fold of that +// file, and copies nothing: the copy waits for the chat's first real read or write. Restore stays +// the cost it was when every chat had its own file, and opens no file it does not restore. + +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentSessionRecord } from '../../../shared/agent-session-record' +import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' +import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-projection' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import Database from '../../sqlite/sync-database' +import type * as SyncDatabaseModule from '../../sqlite/sync-database' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase, + readTestJournalRows +} from '../agent-session-journal/journal-host-database-test-support' +import { + journalDirectoryFor, + legacyJournalDatabaseFile +} from '../agent-session-journal/journal-paths' +import { + readJournalSessionPointer, + type JournalStoredRow +} from '../agent-session-journal/journal-row-table' +import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' +import { journalIdentityFor } from './structured-agent-session-attach' +import { attachParamsForRecord } from './structured-agent-session-conversation-open' +import { StructuredAgentSessionConversations } from './structured-agent-session-conversations' +import { createStructuredAgentSessionConversationLifetime } from './structured-agent-session-conversation-lifetime' +import type { StructuredAgentSessionLifetimeContext } from './structured-agent-session-host-lifetime' +import { createStructuredAgentSessionRestartOfferWithdrawal } from './structured-agent-session-restart-offer-withdrawal' +import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore' + +const { readOnlyOpens, openReadOnly } = vi.hoisted(() => ({ + readOnlyOpens: new Array(), + openReadOnly: new Set() +})) + +// Every read-only open is of a per-chat file: the host's own database opens read-write. +vi.mock('../../sqlite/sync-database', async (importOriginal) => { + const actual = await importOriginal() + class RecordingDatabase extends actual.default { + constructor(...args: ConstructorParameters) { + super(...args) + if (args[1]?.readonly) { + readOnlyOpens.push(String(args[0])) + openReadOnly.add(this) + } + } + + override close(): void { + openReadOnly.delete(this) + super.close() + } + } + return { ...actual, default: RecordingDatabase } +}) + +const WORKSPACE_ID = 'repo-1::/tmp/workspace' +const PROMPT = 'add a retry' + +let root: string +let clock = 1_000 + +function recordFor(sessionId: string): AgentSessionRecord { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore reads only the id, location, provider, handle chain, account home and lease. + return { + schemaVersion: 2, + sessionId, + location: { + executionHostId: 'local', + wslDistro: null, + workspaceId: WORKSPACE_ID, + workspaceKind: 'git-worktree' + }, + provider: 'codex', + providerHandleChain: [ + { + linkId: `codex-1-${sessionId}`, + handle: { provider: 'codex', threadId: `thread-${sessionId}` }, + origin: 'created', + mintedAtFence: 1, + observedAt: 1 + } + ], + accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex-home' }, + createdAt: 1, + updatedAt: 2, + lease: { sessionId, runtimeKind: 'native', runtimeFence: 1 } + } as unknown as AgentSessionRecord +} + +function identityFor(sessionId: string) { + const record = recordFor(sessionId) + return journalIdentityFor( + record, + attachParamsForRecord(record, { clientOperationId: 'seed', expectedRuntimeFence: 1 }) + ) +} + +function legacyDirFor(sessionId: string): string { + return journalDirectoryFor(root, { workspaceId: WORKSPACE_ID, sessionId }) +} + +/** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ +async function seedLegacyChat(sessionId: string, replies = 1): Promise { + const scratch = join(root, `scratch-${sessionId}`) + const identity = identityFor(sessionId) + const journal = await openAgentSessionJournal({ + identity, + database: openTestJournalHostDatabase(scratch), + now: () => (clock += 1) + }) + await journal.appendSubmission({ + clientMessageId: `client-${sessionId}`, + payloadFingerprint: 'fp-1', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: PROMPT }] }, + fence: 1, + handoverRecorded: true + }) + await journal.resolveDispatch({ + clientMessageId: `client-${sessionId}`, + fence: 1, + state: 'accepted', + providerIdentity: { + provider: 'codex', + threadId: `thread-${sessionId}`, + turnId: 't', + ordinal: 0 + } + }) + for (let ordinal = 1; ordinal <= replies; ordinal += 1) { + await journal.appendItem( + { provider: 'codex', threadId: `thread-${sessionId}`, turnId: 't', ordinal }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: `reply ${ordinal}` }] }, + { fence: 1 } + ) + } + await journal.close() + const rows = readTestJournalRows( + openTestJournalHostDatabase(scratch).db, + sessionId, + journal.epoch + ) + const path = legacyJournalDatabaseFile(legacyDirFor(sessionId)) + await mkdir(dirname(path), { recursive: true }) + const db = new Database(path) + try { + db.pragma('journal_mode = WAL') + db.exec(` +CREATE TABLE journal_rows (session_id TEXT NOT NULL, epoch TEXT NOT NULL, seq INTEGER NOT NULL, + ts INTEGER NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY (session_id, epoch, seq)); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, + content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL);`) + db.pragma('user_version = 2') + const insert = db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + for (const row of rows) { + insert.run(sessionId, row.epoch, row.seq, row.ts, row.rowJson) + } + db.prepare('INSERT INTO journal_sessions VALUES (?, ?, ?)').run(sessionId, rows[0]!.epoch, 1) + } finally { + db.close() + } + return rows +} + +function legacyFile(sessionId: string): string { + return legacyJournalDatabaseFile(legacyDirFor(sessionId)) +} + +function hostDb(): Database.Database { + return openTestJournalHostDatabase(root).db +} + +function importCount(): number { + return Number(hostDb().prepare('SELECT count(*) AS n FROM journal_imports').get()?.n) +} + +async function restore(sessionIds: readonly string[]) { + const sessions = new StructuredAgentSessionConversations({ + deliver: () => undefined, + onDeliveryError: () => undefined, + now: () => clock + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and an open conversation's read touch only `getRecord` and `listRecords`. + const store = { + getRecord: (sessionId: string) => recordFor(sessionId), + listRecords: () => sessionIds.map(recordFor) + } as unknown as AgentSessionRecordStore + const deps = { store, adapter: {}, journalDatabase: openTestJournalHostDatabase(root) } + await restoreStructuredAgentSessionsOnRestart({ + openDeps: deps, + records: sessionIds.map(recordFor), + reconcile: async () => null, + resolveRecovery: async () => undefined, + serialize: async (_sessionId, task) => task(), + hasSession: (sessionId) => sessions.has(sessionId), + onReadable: (sessionId, opened) => { + sessions.set(sessionId, opened.session) + } + }) + const lifetime = createStructuredAgentSessionConversationLifetime({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reaching an open conversation reads only `deps.store` and `deps.adapter`. + context: () => ({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext, + sessions, + serialize: async (_sessionId, task) => task(), + open: async (sessionId) => sessions.get(sessionId) ?? null, + deliveryActive: () => false, + closeStatus: () => undefined + }) + return { sessions, lifetime } +} + +function texts(items: readonly { body: unknown }[]): string { + return JSON.stringify(items.map((entry) => entry.body)) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-restore-without-import-')) + clock = 1_000 + readOnlyOpens.length = 0 +}) + +afterEach(async () => { + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) +}) + +describe('startup restore of chats still in their per-chat files', () => { + it('lists every restored chat without copying one, and opens only their files', async () => { + const restored = ['chat-a', 'chat-b', 'chat-c'] + const rows = new Map() + for (const sessionId of [...restored, 'chat-closed']) { + rows.set(sessionId, await seedLegacyChat(sessionId)) + } + readOnlyOpens.length = 0 + const before = new Map( + await Promise.all( + restored.map( + async (sessionId) => [sessionId, await readFile(legacyFile(sessionId))] as const + ) + ) + ) + + const { sessions } = await restore(restored) + + // Each file was closed by the fold that read it, so nothing holds it open for a later rename. + expect(openReadOnly.size).toBe(0) + // Read, never written: the same bytes, in the same place, beside only SQLite's own WAL files. + for (const sessionId of restored) { + expect((await readFile(legacyFile(sessionId))).equals(before.get(sessionId)!)).toBe(true) + const entries = await readdir(legacyDirFor(sessionId)) + expect(entries.filter((name) => !/^journal\.db(-wal|-shm)?$/.test(name))).toEqual([]) + } + + expect([...sessions.keys()].sort()).toEqual(restored) + for (const sessionId of restored) { + const journal = sessions.get(sessionId)!.journal + expect(journal.cursor().sequence).toBe(rows.get(sessionId)!.length) + expect(texts(journal.snapshot().items)).toContain('reply 1') + expect(readJournalSessionPointer(hostDb(), sessionId)).toBeNull() + expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true) + } + // Publishing a restored chat's status saves it for the listing; that is no reason to copy. + for (const sessionId of restored) { + const journal = sessions.get(sessionId)!.journal + journal.saveListingStatus({ status: 'idle', latestPrompt: PROMPT }) + await journal.close() + } + expect(importCount()).toBe(0) + // One read of each restored chat's file, and none of the chat that was not restored. + expect(readOnlyOpens.sort()).toEqual( + restored.map((sessionId) => legacyJournalDatabaseFile(legacyDirFor(sessionId))).sort() + ) + }) + + // T-B3 for a chat restore did not copy: the offer taken before the upgrade still stands. + it('offers the restorable turn of a chat it did not copy', async () => { + const rows = await seedLegacyChat('chat-a') + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: `movedOn` reads only the session id and the journal cursor. + const marker = { + sessionId: 'chat-a', + journalCursor: { epoch: rows[0]!.epoch, sequence: rows.length } + } as AgentSessionResumeMarker + + const { sessions } = await restore(['chat-a']) + const withdrawal = createStructuredAgentSessionRestartOfferWithdrawal({ + sessions, + now: () => clock, + enqueue: (operation) => operation() + }) + + expect(withdrawal.movedOn(marker)).toBe(false) + expect( + latestStructuredAgentSessionPrompt(sessions.get('chat-a')!.journal.snapshot().items) + ).toBe(PROMPT) + expect(importCount()).toBe(0) + }) + + it('copies a chat on its first read, and serves its history verbatim', async () => { + const rows = await seedLegacyChat('chat-a') + const { lifetime, sessions } = await restore(['chat-a']) + const listed = sessions.get('chat-a')!.journal.snapshot() + + const journal = (await lifetime.conversation('chat-a')).journal + const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) + + // The copied chat is the chat restore listed, item for item. + expect(journal.snapshot()).toEqual(listed) + + expect(importCount()).toBe(1) + expect(readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch)).toEqual(rows) + expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) + expect(existsSync(legacyDirFor('chat-a'))).toBe(false) + }) + + it('copies a chat before its first write, and the write lands after its history', async () => { + const rows = await seedLegacyChat('chat-a') + const { sessions } = await restore(['chat-a']) + const journal = sessions.get('chat-a')!.journal + + await journal.appendItem( + { provider: 'codex', threadId: 'thread-chat-a', turnId: 't', ordinal: 9 }, + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, + { fence: 1 } + ) + + const stored = readTestJournalRows(hostDb(), 'chat-a', rows[0]!.epoch) + expect(stored.slice(0, rows.length)).toEqual(rows) + expect(stored).toHaveLength(rows.length + 1) + expect(importCount()).toBe(1) + }) + + it('never shows a read racing the copy a partly copied chat', async () => { + // Past one import batch, so the copy yields to other work between batches. + const rows = await seedLegacyChat('chat-a', 520) + const { lifetime, sessions } = await restore(['chat-a']) + const journal = sessions.get('chat-a')!.journal + const cursor = { epoch: rows[0]!.epoch, sequence: 0 } + const seen: { published: boolean; copied: number; folded: number }[] = [] + const reads: Promise[] = [] + let ticking = true + const tick = (): void => { + seen.push({ + published: readJournalSessionPointer(hostDb(), 'chat-a') !== null, + copied: Number(hostDb().prepare('SELECT count(*) AS n FROM journal_rows').get()?.n), + folded: journal.cursor().sequence + }) + reads.push( + lifetime.conversation('chat-a').then(({ journal: read }) => { + const since = read.readSince(cursor) + return since.ok ? since.rows.length : -1 + }) + ) + if (ticking) { + setImmediate(tick) + } + } + setImmediate(tick) + + await lifetime.conversation('chat-a') + ticking = false + await new Promise((resolve) => setImmediate(resolve)) + + // The copy was under way while other work ran, and none of it saw part of the chat. + expect(seen.some((turn) => !turn.published && turn.copied > 0)).toBe(true) + expect(seen.every((turn) => !turn.published || turn.copied === rows.length)).toBe(true) + expect(seen.every((turn) => turn.folded === rows.length)).toBe(true) + expect(new Set(await Promise.all(reads))).toEqual(new Set([rows.length])) + }) +}) From 68c0614265319dfbf283296235d2baaa9b99b6c4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:48:47 -0700 Subject: [PATCH 27/66] fix(native-chat): no host stays registered after a stop an install spanned Each teardown pass clears the registered host before it awaits an install in flight, and that install registers its host when it finishes. The pass then tore the host down but left it registered, so a request after the stop was served by a host whose journal was closed. The stop now clears the slot once its passes are done. --- ...ed-agent-session-journal-ownership.test.ts | 44 +++++++++++++++++++ .../structured-agent-session-runtime.ts | 2 + 2 files changed, 46 insertions(+) diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index 18d172e2c5fa..799292419b49 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -26,6 +26,7 @@ import Database from '../sqlite/sync-database' import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { AgentSessionRecordStore } from './agent-session-record-store' import { OrcaRuntimeService } from './orca-runtime' import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' @@ -612,6 +613,49 @@ describe('startup and other non-chat work without a structured host', () => { expect(getStructuredAgentSessionHost()).toBeNull() expect(closed).toHaveBeenCalledOnce() }) + + // An install that finishes after a later pass cleared the registry registers the host that pass + // then tears down: no request after the stop may be handed that host. + it('leaves no host registered when a takeover install spans a teardown pass', async () => { + holder = await holdJournalOwnerLockInChild(root) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords } = startupRuntime(async () => { + const host = await install() + vi.spyOn(host, 'reconcileRestartLeases').mockResolvedValue(undefined) + return host + }) + const releaseRefresh = heldOnce(refreshPtyRecords) + const startup = runtime.prepareStructuredAgentSessionStartupRestoration() + await vi.waitFor(() => expect(refreshPtyRecords).toHaveBeenCalledOnce()) + // Only the takeover's install opens the record store, and it waits there. + const openStore = AgentSessionRecordStore.open.bind(AgentSessionRecordStore) + let releaseOpen!: () => void + const openHeld = new Promise((resolve) => (releaseOpen = resolve)) + const opened = vi + .spyOn(AgentSessionRecordStore, 'open') + .mockImplementationOnce(async (input) => { + await openHeld + return openStore(input) + }) + await holder.kill() + holder = null + await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) + const closed = vi.spyOn(JournalHostDatabase.prototype, 'close') + const releaseFlush = holdCatalogFlush() + const stopping = stopStructuredAgentSessionRuntime() + + releaseRefresh() + await startup + await vi.waitFor(() => expect(opened).toHaveBeenCalled(), { timeout: 10_000 }) + releaseFlush() + // The second pass clears the registry and waits on the install, which then registers its host. + await new Promise((resolve) => setTimeout(resolve, 50)) + releaseOpen() + await stopping + + expect(closed).toHaveBeenCalledOnce() + expect(getStructuredAgentSessionHost()).toBeNull() + }) }) it('lets a terminal resume command through while another process owns the chats', async () => { diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 756ea22d0f57..5a0c0617005f 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -188,6 +188,8 @@ export async function stopStructuredAgentSessionRuntime(options?: { } await agentModelCatalogStore.flushPersistence() } while (installing) + // An install a pass awaited registered its host after that pass cleared the slot. + setStructuredAgentSessionHost(null) if (failures.length === 0 && pendingTeardown.size === 0) { releaseStructuredAgentSessionJournal() } From b897ea1cf4afbb323a7ba5b2dea6e93faa1b2c52 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:54:16 -0700 Subject: [PATCH 28/66] fix(native-chat): checkpoint the journal with a full flush on macOS synchronous = FULL fsyncs each commit, but macOS fsync leaves the drive cache unflushed, so FULL alone does not survive a power loss there. With checkpoint_fullfsync, each checkpoint uses F_FULLFSYNC; elsewhere it is a no-op. The comment that said FULL alone was enough is corrected. --- .../agent-session-journal/journal-database.test.ts | 1 + .../native-chat/agent-session-journal/journal-database.ts | 7 +++++-- 2 files changed, 6 insertions(+), 2 deletions(-) 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 4818b76a5253..4f6e8c5f2a3d 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 @@ -83,6 +83,7 @@ describe('the host journal database open', () => { ) 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. 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 767978ebd036..be614c21434b 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -58,9 +58,12 @@ function configureJournalPragmas(db: Database.Database, stored: number): void { 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. + // 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 = FULL') + db.pragma('checkpoint_fullfsync = ON') db.pragma(`journal_size_limit = ${JOURNAL_SIZE_LIMIT_BYTES}`) } From 36d4b19623e848607468aed581f4548fc8d2ebdc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:01:00 -0700 Subject: [PATCH 29/66] fix(native-chat): delete a per-chat journal once its copy verifies An imported chat's per-chat file was kept under an `.imported-*` name, which doubled the disk its history takes. The copy now reads back from the host database before it is published: its items, submissions, epoch and tip must match the file's. Only then does one transaction publish the chat with its import marker, and the file and its WAL files are deleted, the directory too when nothing else is in it (a pre-SQLite transcript there is kept). A copy that does not match is never published: the file stays, the chat is refused as unreadable ("Unable to load this chat."), and the mismatch is logged once. A file left behind by a failed delete or a crash matches the marker, so the next open deletes it rather than copying it again; a file an older build wrote after a downgrade still differs, and is still copied again. --- .../journal-open-failure.ts | 7 +- .../agent-session-journal/journal-paths.ts | 5 +- .../journal-per-session-import.test.ts | 127 ++++++-- .../journal-per-session-import.ts | 284 ++++++++---------- .../journal-per-session-reimport.ts | 5 +- .../journal-per-session-source.ts | 136 +++++++++ 6 files changed, 370 insertions(+), 194 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-per-session-source.ts 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 0e4a1e1985d3..ce22212e02a6 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 @@ -18,6 +18,11 @@ export type JournalOpenFailure = Exclude< 'journalOwnedElsewhere' > +/** 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' +} + // Bounds a cause chain that loops back on itself. const MAX_CAUSE_DEPTH = 8 @@ -25,7 +30,7 @@ const MAX_CAUSE_DEPTH = 8 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) { return 'journalCorrupt' } current = current instanceof Error ? current.cause : undefined 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 7cf6df4471b1..173f36909f37 100644 --- a/src/main/native-chat/agent-session-journal/journal-paths.ts +++ b/src/main/native-chat/agent-session-journal/journal-paths.ts @@ -1,7 +1,8 @@ // Where a chat's history lived when the journal was one database per chat. // -// Nothing writes here any more: the host's one database replaced it. The importer reads a file it -// finds here once, on that chat's first open, and the pre-SQLite format remnant check looks here. +// 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' 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 index 1987fcbbfd3e..3410f6a8778a 100644 --- 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 @@ -1,10 +1,10 @@ // 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 retired only after the copy commits. A file that reappears after -// a downgrade is the newer history, and is copied again. +// that chat's open: verbatim, and deleted only once the copy reads back as the file. A file that +// reappears after a downgrade is the newer history, and is copied again. import type * as NodeFs from 'node:fs' -import { existsSync, renameSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises' +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' @@ -29,7 +29,7 @@ import { readJournalSessionPointer, type JournalStoredRow } from './journal-row- vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, renameSync: vi.fn(actual.renameSync) } + return { ...actual, rmSync: vi.fn(actual.rmSync) } }) const IDENTITY: AgentSessionJournalIdentity = { @@ -112,11 +112,11 @@ CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, } } -/** Every directory an import retired this chat's files to. */ -async function retired(): Promise { +/** 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 prefix = `${legacyDir().slice(parent.length + 1)}.imported-` - return (await readdir(parent)).filter((name) => name.startsWith(prefix)) + 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 { @@ -127,15 +127,47 @@ function rowCount(db: Database.Database): number { return Number(db.prepare('SELECT count(*) AS total FROM journal_rows').get()?.total) } -function renameFails(): void { - vi.mocked(renameSync).mockImplementation(() => { +function removeFails(): void { + vi.mocked(rmSync).mockImplementation(() => { throw Object.assign(new Error('resource busy'), { code: 'EBUSY' }) }) } -async function renameWorks(): Promise { +async function removeWorks(): Promise { const actual = await vi.importActual('node:fs') - vi.mocked(renameSync).mockImplementation(actual.renameSync) + vi.mocked(rmSync).mockImplementation(actual.rmSync) +} + +/** The file as it is, except that the copy's first read of rows loses the first one. */ +function losingFirstCopiedRow(path: string): Database.Database { + const source = new Database(path, { readonly: true, fileMustExist: true }) + let lost = false + return new Proxy(source, { + get(target, key) { + if (key === 'prepare') { + return (sql: string) => { + const statement = target.prepare(sql) + if (lost || !sql.includes('seq > ?')) { + return statement + } + return new Proxy(statement, { + get(inner, name) { + if (name === 'all') { + return (...args: Parameters) => { + lost = true + return inner.all(...args).slice(1) + } + } + const value = Reflect.get(inner, name) + return typeof value === 'function' ? value.bind(inner) : value + } + }) + } + } + const value = Reflect.get(target, key) + return typeof value === 'function' ? value.bind(target) : value + } + }) } function openChat() { @@ -149,13 +181,13 @@ beforeEach(async () => { afterEach(async () => { vi.restoreAllMocks() - await renameWorks() + 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 retires the file', async () => { + it('copies the history verbatim on first open and deletes the file', async () => { const { epoch, rows } = await historyRows() await writeLegacyJournal(epoch, rows) @@ -166,8 +198,8 @@ describe('importing a per-chat journal', () => { expect( readTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId, epoch) ).toEqual(rows) - expect(existsSync(legacyDir())).toBe(false) - expect(await retired()).toEqual([expect.stringMatching(/\.imported-epoch-fr-\d+$/)]) + // 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 @@ -281,12 +313,46 @@ describe('importing a per-chat journal', () => { expect(rowCount(database.db)).toBe(rows.length) }) - // T-R2B1: the copy committed and only the rename failed. Rows appended since, a restart, and a + 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(readJournalSessionPointer(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) + }) + + // 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 rename, across a restart', async () => { + 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) - renameFails() + removeFails() vi.spyOn(console, 'warn').mockImplementation(() => undefined) const journal = await openChat() @@ -298,11 +364,13 @@ describe('importing a per-chat journal', () => { expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) // The process exits: the database closes and the owner lock goes with it. 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(texts(reopened)).not.toContain(JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY.clientMessageId) + expect(await leftovers()).toEqual([]) }) // T-import-transient: a read that fails leaves the file for the next open, which imports it. @@ -353,11 +421,12 @@ describe('importing a per-chat journal', () => { expect(JSON.stringify(journal.snapshot().items)).toContain('log.jsonl') await journals.closeAll() await openChat() - expect(existsSync(legacyDir())).toBe(false) + // 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 retires like any never-written one. + // 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, '')], [ @@ -377,13 +446,11 @@ describe('importing a per-chat journal', () => { expect(journal.snapshot().items).toEqual([]) await journals.closeAll() await openChat() - expect(existsSync(legacyDir())).toBe(false) - expect(await retired()).toHaveLength(1) + expect(await leftovers()).toEqual([]) }) // T-B5: a downgrade, an older build writing the chat's history to a new per-chat file, and a - // re-upgrade — twice. The newer history wins each time, says so, and each file retires to its - // own name. + // re-upgrade — twice. The newer history wins each time, says so, and each file is deleted. it('copies the newer history an older build wrote, on every re-upgrade', async () => { const first = await historyRows() await writeLegacyJournal(first.epoch, first.rows) @@ -408,16 +475,16 @@ describe('importing a per-chat journal', () => { expect(existsSync(legacyDir())).toBe(false) await journals.closeAll() } - expect(await retired()).toHaveLength(3) + expect(await leftovers()).toEqual([]) }) - // N-R3.1: the rename failed, this build appended, and an older build then appended to that same + // N-R3.1: the delete failed, this build appended, and an older build then appended to that same // file. Both advanced from the recorded tip under one epoch, so the copy takes a fresh one: a // reader at this build's tip resets instead of silently skipping the older build's rows. it('gives the copy a fresh epoch when both builds wrote past the same recorded tip', async () => { const { epoch, rows } = await historyRows() await writeLegacyJournal(epoch, rows) - renameFails() + removeFails() vi.spyOn(console, 'warn').mockImplementation(() => undefined) const journal = await openChat() await journal.appendItem( @@ -438,7 +505,7 @@ describe('importing a per-chat journal', () => { .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') .run(IDENTITY.sessionId, epoch, rows.length + 1, 1, JSON.stringify(olderRow)) legacy.close() - await renameWorks() + await removeWorks() const reopened = await openChat() 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 index 1df64240a433..17902d72938a 100644 --- 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 @@ -7,25 +7,25 @@ // journal-per-session-reimport.ts). // // The copy runs in bounded batches, each its own transaction, yielding the event loop between them. -// The rows go into a block `journal_import_blocks` reserves, which no reader follows: the chat's -// pointer, its repair and import markers land in the last batch, so the chat is imported all at once -// or not at all. A try that stops midway leaves only that reserved block, which the next try clears -// and copies again. +// The rows go into a block `journal_import_blocks` reserves, which no reader follows. Once the +// copied block reads back as the file does (items, submissions, epoch, tip), 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 that reserved block, which the next try +// clears and 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. // -// The rename that retires the source runs only after the copy commits, to a name no earlier -// retirement used. 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. +// 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 { existsSync, renameSync } from 'node:fs' +import { existsSync } from 'node:fs' import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import Database from '../../sqlite/sync-database' -import type { SqliteRow } from '../../sqlite/sqlite-statement' +import type Database from '../../sqlite/sync-database' import type { JournalHostDatabase } from './journal-host-database' import { foldJournalRows, type JournalLoad } from './journal-open' +import { JournalImportMismatchError, journalOpenRefusalError } from './journal-open-failure' import { legacyJournalDatabaseFile } from './journal-paths' -import { pendingJournalRepairSequence } from './journal-repair-marker' import { planPerSessionImport, readPerSessionImportMarker, @@ -34,27 +34,28 @@ import { type PerSessionImportPlan, type PerSessionJournalHead } from './journal-per-session-reimport' +import { + foldLegacyJournal, + IMPORT_BATCH_ROWS, + legacyRowBatches, + openLegacySource, + readLegacyHead, + readLegacyRepair, + removeLegacyJournal, + type ImportBatch, + type ImportedRow +} from './journal-per-session-source' +import { parseJournalRow } from './journal-row-schema' import { allocateJournalBlock, deleteJournalBlock, + iterateJournalEpochRows, journalRowId, publishJournalSessionEpoch, - readJournalSessionPointer + readJournalSessionPointer, + readJournalTip } from './journal-row-table' -/** 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). */ -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 = ?' const INSERT_ROW = 'INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)' const SELECT_IMPORT_BLOCK = 'SELECT block FROM journal_import_blocks WHERE session_id = ?' const RESERVE_IMPORT_BLOCK = 'INSERT INTO journal_import_blocks (session_id, block) VALUES (?, ?)' @@ -66,7 +67,8 @@ ON CONFLICT(session_id) DO UPDATE SET export type PerSessionJournalImportDeps = { openSource?: (path: string) => Database.Database - rename?: (from: string, to: string) => void + /** Deletes one of the per-chat files. */ + remove?: (path: string) => void now?: () => number batchRows?: number } @@ -79,15 +81,6 @@ type ImportInput = { legacyDirectory: string } & PerSessionJournalImportDeps -/** Where a copied directory goes: a name no earlier retirement of the same chat used. */ -export function importedLegacyJournalDirectory( - legacyDirectory: string, - epoch: string, - now: number -): string { - return `${legacyDirectory}.imported-${epoch.slice(0, 8)}-${now}` -} - /** Imports in flight, by database and chat: a second open of the same chat waits for the first. */ const importsInFlight = new WeakMap>>() @@ -138,10 +131,11 @@ async function importOnce(input: ImportInput): Promise 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 - } -} - -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 } -} - -type ImportedRow = { seq: number; ts: number; rowJson: string } -type ImportBatch = { rows: ImportedRow[]; last: boolean } - -/** The file's rows, one bounded page per batch, read as each batch is written. */ -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 - } -} - function* arrayBatches(rows: readonly ImportedRow[], batchRows: number): Generator { for (let from = 0; ; from += batchRows) { const last = from + batchRows >= rows.length @@ -258,8 +182,8 @@ function* arrayBatches(rows: readonly ImportedRow[], batchRows: number): Generat } /** - * Batches into a reserved block no reader follows; the last one publishes the chat's pointer, its - * repair marker and the import marker together. + * Batches into a reserved block no reader follows. Once the block reads back as the file does, one + * transaction publishes the chat's pointer with its repair marker and the import marker. */ async function copyLegacyJournal( input: ImportInput, @@ -272,21 +196,21 @@ async function copyLegacyJournal( const repair = readLegacyRepair(source, sessionId) const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS // A second copy is read whole: it is rewritten under a fresh epoch or gains a disclosure row. - const batches = + const rewritten = plan.kind === 'again' - ? arrayBatches( - reimportedJournalRows({ - sessionId, - legacyEpoch: legacy.epoch, - epoch, - rows: [...legacyRowBatches(source, sessionId, legacy.epoch, batchRows)].flatMap( - (batch) => batch.rows - ), - now: (input.now ?? Date.now)() - }), - batchRows - ) - : legacyRowBatches(source, sessionId, legacy.epoch, batchRows) + ? reimportedJournalRows({ + sessionId, + legacyEpoch: legacy.epoch, + epoch, + rows: [...legacyRowBatches(source, sessionId, legacy.epoch, batchRows)].flatMap( + (batch) => batch.rows + ), + now: (input.now ?? Date.now)() + }) + : null + const batches = rewritten + ? arrayBatches(rewritten, batchRows) + : legacyRowBatches(source, sessionId, legacy.epoch, batchRows) let block: number | null = null for (const batch of batches) { if (block !== null) { @@ -299,32 +223,87 @@ async function copyLegacyJournal( // Copied as stored: the bytes are the row, its epoch and sequence included. insert.run(journalRowId(target, row.seq), row.ts, row.rowJson) } - if (batch.last) { - const retired = readJournalSessionPointer(db, sessionId) - if (retired) { - deleteJournalBlock(db, retired.block) - } - publishJournalSessionEpoch(db, input.identity, { epoch, block: target }) - db.prepare(RELEASE_IMPORT_BLOCK).run(sessionId) - if (repair) { - db.prepare(UPSERT_REPAIR).run( - sessionId, - repair.epoch === legacy.epoch ? epoch : repair.epoch, - repair.content_from, - repair.repaired_at - ) - } - writePerSessionImportMarker(db, sessionId, legacy) - } return target }) } + const target = block ?? input.database.transaction((db) => reserveImportBlock(db, sessionId)) + verifyCopiedJournal( + input, + { epoch, block: target }, + { + load: rewritten + ? foldJournalRows({ + sessionId, + pointer: { epoch, block: -1 }, + repairedFrom: null, + rows: rewritten + }) + : foldLegacyJournal(source, sessionId, legacy), + tip: rewritten ? (rewritten.at(-1)?.seq ?? 0) : legacy.tip + } + ) + input.database.transaction((db) => { + const retired = readJournalSessionPointer(db, sessionId) + if (retired) { + deleteJournalBlock(db, retired.block) + } + publishJournalSessionEpoch(db, input.identity, { epoch, block: target }) + db.prepare(RELEASE_IMPORT_BLOCK).run(sessionId) + if (repair) { + db.prepare(UPSERT_REPAIR).run( + sessionId, + repair.epoch === legacy.epoch ? epoch : repair.epoch, + repair.content_from, + repair.repaired_at + ) + } + writePerSessionImportMarker(db, sessionId, legacy) + }) if (plan.kind === 'again') { // The block this copy replaced. void input.database.reclaimFreePages() } } +/** Mismatches already logged, so a chat refused on every open logs once. */ +const loggedMismatches = new Set() + +/** + * The copied block, read back from the host's database, against the chat the file holds: the same + * items, submissions, epoch and tip, or the copy is refused and never published. + */ +function verifyCopiedJournal( + input: ImportInput, + copied: { epoch: string; block: number }, + expected: { load: JournalLoad; tip: number } +): void { + const { sessionId } = input.identity + const db = input.database.db + const read = foldJournalRows({ + sessionId, + pointer: copied, + repairedFrom: null, + rows: iterateJournalEpochRows(db, copied) + }) + const first = iterateJournalEpochRows(db, copied).next() + const parsed = first.done ? null : parseJournalRow(first.value.rowJson) + const facts = (load: JournalLoad, epoch: string | null, tip: number) => + `${epoch}:${tip}:${load.state.items.size}:${load.state.submissions.size}` + const want = facts(expected.load, copied.epoch, expected.tip) + const got = facts(read, parsed?.ok ? parsed.row.epoch : null, readJournalTip(db, copied.block)) + 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) +} + /** The block this chat's copy goes into. One an earlier try left behind is emptied and reused. */ function reserveImportBlock(db: Database.Database, sessionId: string): number { const staged = db.prepare(SELECT_IMPORT_BLOCK).get(sessionId)?.block @@ -337,24 +316,11 @@ function reserveImportBlock(db: Database.Database, sessionId: string): number { return block } -/** The per-chat repair marker, as stored; v1 files predate the table. */ -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 -} - -/** Best effort: the copy is committed, so a failed rename only leaves a file the next open skips. */ -function retireImportedDirectory(input: ImportInput, epoch: string): void { - const target = importedLegacyJournalDirectory( - input.legacyDirectory, - epoch, - (input.now ?? Date.now)() - ) +/** Best effort: the copy is committed, so a file left behind is deleted by the next open. */ +function retireLegacyJournal(input: ImportInput): void { try { - ;(input.rename ?? renameSync)(input.legacyDirectory, target) + removeLegacyJournal(input.legacyDirectory, input.remove) } catch (error) { - console.warn(`[agent-session-journal] retiring imported ${input.legacyDirectory} failed`, error) + console.warn(`[agent-session-journal] deleting imported ${input.legacyDirectory} failed`, error) } } 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 index d8a45dc584b7..c77f63636fd4 100644 --- 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 @@ -2,8 +2,9 @@ // downgrade, attached the chat and wrote its history there. That file is the newer history. // // `journal_imports` records which file each chat was copied from — its epoch and tip — in the -// same transaction as the copy, so a file already copied (only its rename failed, across any -// number of restarts) is never copied again, and a file that differs always is. Newest writer +// transaction that publishes the verified copy, so a file already copied (only its delete failed, +// or a crash came first, across any number of restarts) is deleted, never copied again, and a +// file that differs always is. Newest writer // wins, per chat, and the chat says so. When both builds advanced one epoch from the recorded // tip, the copy takes a fresh epoch, so every reader resets instead of silently skipping rows. 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..20b667c6cd06 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-per-session-source.ts @@ -0,0 +1,136 @@ +// 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 Database from '../../sqlite/sync-database' +import type { SqliteRow } from '../../sqlite/sqlite-statement' +import { foldJournalRows, 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 = ?' + +export 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 + } +} + +export function* legacyRows( + source: Database.Database, + sessionId: string, + epoch: string +): Generator { + for (const batch of legacyRowBatches(source, sessionId, epoch, IMPORT_BATCH_ROWS)) { + yield* batch.rows + } +} + +/** 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 function foldLegacyJournal( + source: Database.Database, + sessionId: string, + legacy: PerSessionJournalHead +): JournalLoad { + return foldJournalRows({ + sessionId, + pointer: { epoch: legacy.epoch, block: -1 }, + repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs') + ? pendingJournalRepairSequence(source, sessionId, legacy.epoch) + : null, + rows: legacyRows(source, sessionId, legacy.epoch) + }) +} + +/** + * 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. + */ +export function removeLegacyJournal( + legacyDirectory: string, + remove: (path: string) => void = (path) => rmSync(path, { force: true }) +): void { + const file = legacyJournalDatabaseFile(legacyDirectory) + // 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 copied again as newer history. + for (const path of [file, `${file}-wal`, `${file}-shm`]) { + remove(path) + } + try { + rmdirSync(legacyDirectory) + } catch { + // Not empty: something beside the journal is kept. + } +} From 40a26c30c449a0d61afdc2839cf3bbecb9d3851a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:08:02 -0700 Subject: [PATCH 30/66] fix(native-chat): verify an imported chat a batch at a time The check that a copied chat reads back as its per-chat file folded both whole, each in one synchronous task: over half a second on the largest chat. Both reads now go a batch at a time between turns of the event loop, like the copy itself, and count rows as well, so a copy that lost a row with no item in it is caught too. --- .../journal-per-session-import.ts | 100 +++++++++++------- 1 file changed, 63 insertions(+), 37 deletions(-) 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 index 17902d72938a..3d56e7bed153 100644 --- 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 @@ -8,7 +8,7 @@ // // The copy runs in bounded batches, each its own transaction, yielding the event loop between them. // The rows go into a block `journal_import_blocks` reserves, which no reader follows. Once the -// copied block reads back as the file does (items, submissions, epoch, tip), one transaction +// copied block reads back as the file does (epoch, tip, rows, items, submissions), 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 that reserved block, which the next try // clears and copies again. A copy that does not read back as the file is never published: the @@ -23,7 +23,7 @@ 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 { foldJournalRows, type JournalLoad } from './journal-open' +import type { JournalLoad } from './journal-open' import { JournalImportMismatchError, journalOpenRefusalError } from './journal-open-failure' import { legacyJournalDatabaseFile } from './journal-paths' import { @@ -46,14 +46,14 @@ import { type ImportedRow } from './journal-per-session-source' import { parseJournalRow } from './journal-row-schema' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { allocateJournalBlock, deleteJournalBlock, - iterateJournalEpochRows, journalRowId, publishJournalSessionEpoch, - readJournalSessionPointer, - readJournalTip + readJournalRowsAfter, + readJournalSessionPointer } from './journal-row-table' const INSERT_ROW = 'INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)' @@ -227,20 +227,12 @@ async function copyLegacyJournal( }) } const target = block ?? input.database.transaction((db) => reserveImportBlock(db, sessionId)) - verifyCopiedJournal( + await verifyCopiedJournal( input, - { epoch, block: target }, - { - load: rewritten - ? foldJournalRows({ - sessionId, - pointer: { epoch, block: -1 }, - repairedFrom: null, - rows: rewritten - }) - : foldLegacyJournal(source, sessionId, legacy), - tip: rewritten ? (rewritten.at(-1)?.seq ?? 0) : legacy.tip - } + rewritten + ? arrayBatches(rewritten, batchRows) + : legacyRowBatches(source, sessionId, legacy.epoch, batchRows), + { epoch, block: target } ) input.database.transaction((db) => { const retired = readJournalSessionPointer(db, sessionId) @@ -269,28 +261,19 @@ async function copyLegacyJournal( const loggedMismatches = new Set() /** - * The copied block, read back from the host's database, against the chat the file holds: the same - * items, submissions, epoch and tip, or the copy is refused and never published. + * The copied block, read back from the host's database, against a second read of what was copied: + * 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. */ -function verifyCopiedJournal( +async function verifyCopiedJournal( input: ImportInput, - copied: { epoch: string; block: number }, - expected: { load: JournalLoad; tip: number } -): void { + expected: Iterable, + copied: { epoch: string; block: number } +): Promise { const { sessionId } = input.identity - const db = input.database.db - const read = foldJournalRows({ - sessionId, - pointer: copied, - repairedFrom: null, - rows: iterateJournalEpochRows(db, copied) - }) - const first = iterateJournalEpochRows(db, copied).next() - const parsed = first.done ? null : parseJournalRow(first.value.rowJson) - const facts = (load: JournalLoad, epoch: string | null, tip: number) => - `${epoch}:${tip}:${load.state.items.size}:${load.state.submissions.size}` - const want = facts(expected.load, copied.epoch, expected.tip) - const got = facts(read, parsed?.ok ? parsed.row.epoch : null, readJournalTip(db, copied.block)) + const want = await copyFacts(sessionId, expected) + const got = await copyFacts(sessionId, copiedBatches(input, copied)) if (want === got) { return } @@ -304,6 +287,49 @@ function verifyCopiedJournal( throw journalOpenRefusalError(error) } +/** Epoch, tip, row count, items and submissions, folded a batch at a time. */ +async function copyFacts(sessionId: string, batches: Iterable): Promise { + const state = createJournalReducerState(sessionId, '') + 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) + 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}` +} + +function* copiedBatches( + input: ImportInput, + copied: { epoch: string; block: number } +): Generator { + const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS + let afterSeq = 0 + for (;;) { + const rows = readJournalRowsAfter(input.database.db, copied, afterSeq, batchRows) + const lastSeq = rows.at(-1)?.seq + const last = rows.length < batchRows || lastSeq === undefined + yield { rows, last } + if (last) { + return + } + afterSeq = lastSeq + } +} + /** The block this chat's copy goes into. One an earlier try left behind is emptied and reused. */ function reserveImportBlock(db: Database.Database, sessionId: string): number { const staged = db.prepare(SELECT_IMPORT_BLOCK).get(sessionId)?.block From 271199c7085b77258b73a152b6a481f1bf28d972 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:15:06 -0700 Subject: [PATCH 31/66] fix(native-chat): restore reads a chat's per-chat file a batch at a time Restore folded a chat still in its per-chat file in one task, so the largest chat's file held the main thread for about half a second at startup. The fold now takes the file a batch of rows per turn of the event loop, into the same fold a replay uses, and nothing reads it before it is done. The file is still closed before restore moves on. --- .../agent-session-journal/journal-open.ts | 68 ++++++++++++------- .../journal-per-session-import.ts | 6 +- .../journal-per-session-source.ts | 34 +++++----- .../journal-store-restore.ts | 2 +- ...ent-session-restore-without-import.test.ts | 26 +++++++ 5 files changed, 93 insertions(+), 43 deletions(-) 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 f313782581ed..22dced209165 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -56,13 +56,31 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal } /** Folds one epoch's stored rows, in sequence order, wherever they are stored. */ -export function foldJournalRows(input: { +export 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() +} + +export type JournalRowFoldInput = { sessionId: string pointer: JournalBlockPointer /** The sequence a pending repair on this epoch left free. */ repairedFrom: number | null - rows: Iterable<{ seq: number; rowJson: string }> -}): JournalLoad { +} + +/** 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 { pointer, repairedFrom } = input const state = createJournalReducerState(input.sessionId, pointer.epoch) let expectedSequence = FIRST_JOURNAL_SEQUENCE @@ -75,22 +93,22 @@ export function foldJournalRows(input: { let latched = false let truncateFrom: number | undefined - for (const entry of input.rows) { + 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) { @@ -101,7 +119,7 @@ export function foldJournalRows(input: { } } if (!anchor) { - continue + return true } applyJournalRow(state, row) const disclosure = row.kind === 'item' && row.itemId === JOURNAL_REPAIR_DISCLOSURE_ITEM_ID @@ -109,23 +127,27 @@ export function foldJournalRows(input: { 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, - block: pointer.block, - 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, + block: pointer.block, + 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 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 index 3d56e7bed153..4f7f5fbb3878 100644 --- 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 @@ -145,9 +145,9 @@ async function importOnce(input: ImportInput): Promise -): JournalLoad | null { +): Promise { const { sessionId } = input.identity const db = input.database.db const sourcePath = legacyJournalDatabaseFile(input.legacyDirectory) @@ -164,7 +164,7 @@ export function previewPerSessionJournal( if (!legacy) { return null } - const loaded = foldLegacyJournal(source, sessionId, legacy) + const loaded = await foldLegacyJournal(source, sessionId, legacy) return loaded.corrupt || loaded.readOnly || loaded.truncateFrom !== undefined ? null : loaded } finally { source.close() 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 index 20b667c6cd06..c53975e5f8a7 100644 --- 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 @@ -2,9 +2,10 @@ // 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 { foldJournalRows, type JournalLoad } from './journal-open' +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' @@ -80,16 +81,6 @@ export function* legacyRowBatches( } } -export function* legacyRows( - source: Database.Database, - sessionId: string, - epoch: string -): Generator { - for (const batch of legacyRowBatches(source, sessionId, epoch, IMPORT_BATCH_ROWS)) { - yield* batch.rows - } -} - /** 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')) { @@ -99,19 +90,30 @@ export function readLegacyRepair(source: Database.Database, sessionId: string): } /** The chat as its file holds it, folded the way a replay folds the host's database. */ -export function foldLegacyJournal( +export async function foldLegacyJournal( source: Database.Database, sessionId: string, legacy: PerSessionJournalHead -): JournalLoad { - return foldJournalRows({ +): Promise { + const fold = startJournalRowFold({ sessionId, pointer: { epoch: legacy.epoch, block: -1 }, repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs') ? pendingJournalRepairSequence(source, sessionId, legacy.epoch) - : null, - rows: legacyRows(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() } /** 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 8c3a69cfdd07..998829bf53af 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 @@ -23,7 +23,7 @@ export async function restoreJournalStore( legacyDirectory: host.legacyDirectory } // A restore reads a chat still in its per-chat file from there, and copies it before its first use. - const preview = host.deferPerSessionImport ? previewPerSessionJournal(source) : null + const preview = host.deferPerSessionImport ? await previewPerSessionJournal(source) : null if (preview) { host.owe(async () => { await importPerSessionJournal(source) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index e6bfa04dcf0a..8e0f5e411b86 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -26,6 +26,7 @@ import { readJournalSessionPointer, type JournalStoredRow } from '../agent-session-journal/journal-row-table' +import { previewPerSessionJournal } from '../agent-session-journal/journal-per-session-import' import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory' import { journalIdentityFor } from './structured-agent-session-attach' import { attachParamsForRecord } from './structured-agent-session-conversation-open' @@ -281,6 +282,31 @@ describe('startup restore of chats still in their per-chat files', () => { ) }) + it('lets other work run while it reads a large per-chat file', async () => { + // Past one batch of the file's rows. + const rows = await seedLegacyChat('chat-a', 520) + let turns = 0 + let ticking = true + const tick = (): void => { + turns += 1 + if (ticking) { + setImmediate(tick) + } + } + + setImmediate(tick) + const folded = await previewPerSessionJournal({ + database: openTestJournalHostDatabase(root), + identity: identityFor('chat-a'), + legacyDirectory: legacyDirFor('chat-a') + }) + ticking = false + + expect(turns).toBeGreaterThan(0) + expect(folded?.state.lastSequence).toBe(rows.length) + expect(openReadOnly.size).toBe(0) + }) + // T-B3 for a chat restore did not copy: the offer taken before the upgrade still stands. it('offers the restorable turn of a chat it did not copy', async () => { const rows = await seedLegacyChat('chat-a') From 235ea68e4a6c89badf8d1ffbe6729d3a1849bc40 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 11:38:28 -0700 Subject: [PATCH 32/66] fix(native-chat): end a per-chat copy on a turn of its own A chat's first open ran the copy's last steps (the verified publish and the per-chat file delete) and the replay of what was copied in one task. The copy now yields before it returns, so the replay, which every open runs, is a task of its own. --- .../journal-per-session-import.test.ts | 19 +++++++++++++++++++ .../journal-per-session-import.ts | 7 ++++++- 2 files changed, 25 insertions(+), 1 deletion(-) 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 index 3410f6a8778a..757ade06d8fb 100644 --- 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 @@ -313,6 +313,25 @@ describe('importing a per-chat journal', () => { expect(rowCount(database.db)).toBe(rows.length) }) + 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) 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 index 4f7f5fbb3878..ba15938e0105 100644 --- 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 @@ -136,7 +136,12 @@ async function importOnce(input: ImportInput): Promise Date: Mon, 28 Sep 2026 11:50:04 -0700 Subject: [PATCH 33/66] fix(native-chat): commit a per-chat copy's batches without an fsync each Each 512-row batch of a chat's first-use copy committed under synchronous = FULL, so a large chat paid one fsync per batch, about a quarter of its first open. The batches now commit under NORMAL, set and restored in the batch's own task so no other chat's commit runs under it. The publish that makes the copy visible still commits under FULL, and under WAL that sync makes every earlier batch durable with it. A crash before it leaves only the unpublished block, which the next open clears and copies again. --- .../journal-host-database.ts | 15 +++++++ .../journal-per-session-import.test.ts | 42 +++++++++++++++++++ .../journal-per-session-import.ts | 3 +- 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index df63f9f2c006..6c42632e0d91 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -73,6 +73,21 @@ export class JournalHostDatabase { return result } + /** + * 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 synchronous = Number(this.db.pragma('synchronous', { simple: true })) + this.db.pragma('synchronous = NORMAL') + try { + return this.transaction(run) + } finally { + this.db.pragma(`synchronous = ${synchronous}`) + } + } + /** Where this chat's history lived before the journal was one database per host. */ legacyDirectoryFor( identity: Pick 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 index 757ade06d8fb..80a90cf14f75 100644 --- 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 @@ -313,6 +313,48 @@ describe('importing a per-chat journal', () => { 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) 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 index ba15938e0105..18ddffd8b4a5 100644 --- 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 @@ -221,7 +221,8 @@ async function copyLegacyJournal( if (block !== null) { await yieldToEventLoop() } - block = input.database.transaction((db) => { + // Unsynced: no reader follows the reserved block, and the publish's synced commit covers it. + block = input.database.unsyncedTransaction((db) => { const target = block ?? reserveImportBlock(db, sessionId) const insert = db.prepare(INSERT_ROW) for (const row of batch.rows) { From e1a85467cda284be2648ce6321c8bcc2c9f5ef82 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:04:44 -0700 Subject: [PATCH 34/66] fix(native-chat): roll back a chat journal transaction whose COMMIT fails The shared connection's transaction rolled back only when its body threw. A COMMIT that failed left the transaction open, so every later write, for any chat, failed with "cannot start a transaction within a transaction", and reads saw rows that never committed. Under the unsynced copy the failure also tried to restore the sync level inside the open transaction, which SQLite refuses, so the caller got that error instead of the COMMIT's. One transaction helper now covers the body and the COMMIT, rolls back whatever transaction survives, and rethrows the original error. Schema creation uses it too. If that ROLLBACK fails as well, the connection is marked stranded: each later use retries the ROLLBACK, and until one goes through every chat gets the same "history unavailable, try again" refusal a journal that will not open gives. The rollback that frees it also restores the FULL sync level. --- .../agent-session-journal/journal-database.ts | 41 +++++++- .../journal-host-database.test.ts | 97 +++++++++++++++++++ .../journal-host-database.ts | 66 ++++++++----- 3 files changed, 175 insertions(+), 29 deletions(-) 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 be614c21434b..387fd5c39da2 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -11,6 +11,8 @@ import { createJournalTablesSql, JOURNAL_DB_SCHEMA_VERSION } from './journal-dat 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' /** A newer build wrote this database: this build neither reads nor writes it. */ export class JournalDatabaseNewerSchemaError extends Error { @@ -62,7 +64,7 @@ function configureJournalPragmas(db: Database.Database, stored: number): void { // 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 = FULL') + db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) db.pragma('checkpoint_fullfsync = ON') db.pragma(`journal_size_limit = ${JOURNAL_SIZE_LIMIT_BYTES}`) } @@ -75,13 +77,44 @@ 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-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts index d1d2abd31996..ea786a1db012 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -19,6 +19,7 @@ import { readTestJournalRows } from './journal-host-database-test-support' import { deleteJournalBlock, journalRowId } from './journal-row-table' +import type Database from '../../sqlite/sync-database' const IDENTITY: AgentSessionJournalIdentity = { sessionId: 'session-1', @@ -28,6 +29,23 @@ const IDENTITY: AgentSessionJournalIdentity = { 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() @@ -35,6 +53,18 @@ 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, @@ -98,6 +128,73 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, 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 }) + ).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 (id, ts, row_json) VALUES (?, ?, ?)') + .run(journalRowId(99, 1), 1, '{}') + ) + ).toThrow('FOREIGN KEY constraint failed') + expect(connection.isTransaction).toBe(true) + await expect(other.appendItem(item(1), text('refused'), { fence: 1 })).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 })).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') diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index 6c42632e0d91..e4a5b36b15b7 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -9,7 +9,8 @@ import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import { freePageCount, reclaimFreePagesStep } from '../../sqlite/sqlite-free-page-reclaim' -import { openJournalDatabase } from './journal-database' +import { JOURNAL_SYNCHRONOUS, openJournalDatabase, runJournalTransaction } from './journal-database' +import { journalOpenRefusalError } from './journal-open-failure' import type { JournalOwnerLock } from './journal-owner-lock' import { journalDirectoryFor } from './journal-paths' import { AgentSessionJournalError } from './journal-write-guards' @@ -23,6 +24,8 @@ export function journalDatabasePath(stateDirectory: string): string { export class JournalHostDatabase { private connection: Database.Database | null private reclaiming: Promise | null = null + /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ + private stranded = false private constructor( readonly stateDirectory: string, @@ -48,29 +51,21 @@ export class JournalHostDatabase { } get db(): Database.Database { - if (!this.connection) { + const connection = this.connection + if (!connection) { throw new AgentSessionJournalError('journal_closed', 'the chat journal database is closed') } - return this.connection + if (this.stranded) { + this.rollBackStrandedTransaction(connection) + } + return connection } - /** One IMMEDIATE transaction. `run` is synchronous by contract: an await inside it would let - * another chat's statements land in this transaction on the shared connection. */ + /** One IMMEDIATE transaction; see `runJournalTransaction`. */ transaction(run: (db: Database.Database) => T): T { - const db = this.db - db.exec('BEGIN IMMEDIATE') - let result: T - try { - result = run(db) - if (result instanceof Promise) { - throw new Error('a chat journal transaction must not await') - } - } catch (error) { - db.exec('ROLLBACK') - throw error - } - db.exec('COMMIT') - return result + return runJournalTransaction(this.db, run, () => { + this.stranded = true + }) } /** @@ -79,12 +74,15 @@ export class JournalHostDatabase { * in the same task, so no other chat's commit runs under it. */ unsyncedTransaction(run: (db: Database.Database) => T): T { - const synchronous = Number(this.db.pragma('synchronous', { simple: true })) - this.db.pragma('synchronous = NORMAL') + const db = this.db + db.pragma('synchronous = NORMAL') try { return this.transaction(run) } finally { - this.db.pragma(`synchronous = ${synchronous}`) + // SQLite refuses the change inside a transaction; freeing a stranded one restores it. + if (!db.isTransaction) { + db.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) + } } } @@ -112,12 +110,30 @@ export class JournalHostDatabase { connection?.close() } + /** + * 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 + } + private async runReclaim(): Promise { try { await yieldToEventLoop() - while (this.connection) { - const before = freePageCount(this.connection) - const remaining = reclaimFreePagesStep(this.connection) + while (!this.isClosed) { + const db = this.db + const before = freePageCount(db) + const remaining = reclaimFreePagesStep(db) // A step that frees nothing (a file created without incremental auto-vacuum) ends the pass; // pages a delete freed since the last step do not. if (remaining === 0 || remaining >= before) { From 791f8cc744b1bba27129d84520055828184df890 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:11:39 -0700 Subject: [PATCH 35/66] fix(native-chat): keep the chat journal connection until its close succeeds Closing the journal dropped its connection handle before closing it. A close that failed left the database reporting itself closed with the connection still open, so the stop that retried the teardown found nothing to close and released the owner lock over a live connection. The handle is now dropped only once the close succeeds. A failed close keeps the runtime pending and the lock held, and the next stop closes that same connection before it releases the lock. --- .../journal-host-database.ts | 6 ++--- ...ed-agent-session-journal-ownership.test.ts | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index e4a5b36b15b7..52231d0875bb 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -103,11 +103,11 @@ export class JournalHostDatabase { return this.reclaiming } - /** Last, after every store has drained. */ + /** Last, after every store has drained. A close that fails keeps the handle, so the retried + * teardown closes this same connection before the owner lock goes. */ close(): void { - const connection = this.connection + this.connection?.close() this.connection = null - connection?.close() } /** diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index 799292419b49..8334d37f26bd 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -697,4 +697,29 @@ describe('releasing ownership', () => { expect(await probeJournalOwnerLockInChild(root)).toBe('acquired') }) + + it('keeps the chats until a journal close that failed is retried and goes through', async () => { + await install() + const close = JournalHostDatabase.prototype.close + let connectionClose: ReturnType | null = null + const journalClose = vi + .spyOn(JournalHostDatabase.prototype, 'close') + .mockImplementation(function (this: JournalHostDatabase) { + // The connection's first close fails, as a native close can before it completes. + connectionClose ??= vi.spyOn(this.db, 'close').mockImplementationOnce(() => { + throw new Error('unable to close due to unfinalized statements') + }) + close.call(this) + }) + const journal = () => journalClose.mock.contexts[0] + + await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow('unable to close') + expect(journal()).toHaveProperty('isClosed', false) + expect(await probeJournalOwnerLockInChild(root)).toBe('refused') + + await stopStructuredAgentSessionRuntime() + expect(connectionClose).toHaveBeenCalledTimes(2) + expect(journal()).toHaveProperty('isClosed', true) + expect(await probeJournalOwnerLockInChild(root)).toBe('acquired') + }) }) From 67f5e35adec6f4da0fad5211d086ab58b149394f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:17:44 -0700 Subject: [PATCH 36/66] fix(native-chat): publish the runtime only once it holds the chat journal lock When this process could not open the owner lock file at all (a permission error, or a file that is not a database), the runtime counted that as owning the chats and wrote orca-runtime.json. That overwrote the real owner's entry, so the CLI was sent to a process that cannot serve its chats. A claim that throws is now refused like one another process holds: the runtime starts but does not publish, the claim's existing retry keeps asking for the lock, and discovery publishes once the retry takes it. Chats still get the refusal for the failure itself, and startup restoration reruns on the takeover the same way it does after another owner quits. A sole process whose lock file never opens is not found by the CLI until it does. --- ...structured-agent-session-create-support.ts | 6 +- .../runtime-rpc/runtime-rpc-lifecycle.ts | 5 +- .../structured-agent-session-journal-open.ts | 2 +- ...ed-agent-session-journal-ownership.test.ts | 34 +++++++++ ...uctured-agent-session-journal-ownership.ts | 75 +++++++++++++------ 5 files changed, 94 insertions(+), 28 deletions(-) diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 3bc97340b19e..2134d48016f4 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -21,8 +21,8 @@ import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-ses import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' import { ensureStructuredAgentSessionHostUnlessRefused, - onStructuredAgentSessionJournalOwned, - structuredAgentSessionJournalOwnerRefusal + isStructuredAgentSessionJournalClaimRefused, + onStructuredAgentSessionJournalOwned } from './structured-agent-session-journal-ownership' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' @@ -278,7 +278,7 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR ) // Read now: a host a chat request installs later is reconciled by the takeover's own run. const host = getStructuredAgentSessionHost() - if (!host && structuredAgentSessionJournalOwnerRefusal()) { + if (!host && isStructuredAgentSessionJournalClaimRefused()) { this.restoreAgainOnceJournalOwned() } await this.refreshMobileSessionPtyRecords() diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 170348c961a8..45ce544dc295 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -154,9 +154,10 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { try { return claimStructuredAgentSessionJournal(this.journalStateDirectory) !== null } catch (error) { - // An unreadable lock file must not stop the runtime; chats report it on their own open. + // A lock file that will not open proves no ownership: the runtime runs undiscovered until + // the claim's retry takes the lock, and chats report the failure on their own open. console.error('[runtime] Claiming the chat journal owner lock failed:', error) - return true + return false } } diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 754af523ace4..5207273e8e1e 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -25,7 +25,7 @@ function logOpenFailureOnce(error: unknown): void { /** The journal database, opened only under this process's owner lock. A refusal is recorded for * the gate and thrown to the caller; the next install tries again. A lock file that cannot be - * opened at all refuses like the database it guards. */ + * opened at all refuses like the database it guards, until the claim's retry takes the lock. */ export function openOwnedJournalDatabase(stateDirectory: string): JournalHostDatabase { try { const lock = claimStructuredAgentSessionJournal(stateDirectory) diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts index 8334d37f26bd..2de90feaf426 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.test.ts @@ -31,6 +31,7 @@ import { OrcaRuntimeService } from './orca-runtime' import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' import type { RpcContext } from './rpc/core' +import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' import { readRuntimeMetadata } from './runtime-metadata' import { OrcaRuntimeRpcServer } from './runtime-rpc' import { @@ -170,6 +171,33 @@ describe('a second process on the same profile', () => { await server.stop() } }) + + // Not opening the lock file proves no ownership: the process that does own the chats stays the + // one the CLI finds, until this one's retry takes the lock. + it('does not publish over the owner while its lock file will not open', async () => { + const lockPath = join(root, JOURNAL_OWNER_LOCK_FILE) + await writeFile(lockPath, 'not a database '.repeat(512)) + const metadataPath = getRuntimeMetadataPath(root) + await writeFile(metadataPath, JSON.stringify({ pid: 999_999, runtimeId: 'the-owner' })) + const before = await digest(metadataPath) + vi.spyOn(console, 'error').mockImplementation(() => undefined) + const server = new OrcaRuntimeRpcServer({ + runtime: new OrcaRuntimeService(), + userDataPath: root, + journalStateDirectory: root + }) + try { + await server.start() + expect(await digest(metadataPath)).toBe(before) + + await unlink(lockPath) + await vi.waitFor(() => expect(readRuntimeMetadata(root)?.pid).toBe(process.pid), { + timeout: 10_000 + }) + } finally { + await server.stop() + } + }) }) describe('the owner, when its journal will not open', () => { @@ -334,6 +362,12 @@ describe('startup and other non-chat work without a structured host', () => { reason: 'journalCorrupt', message: 'Unable to load this chat.' }) + + // The claim keeps retrying; once the file opens, the takeover installs as it does after an owner quits. + await unlink(join(root, JOURNAL_OWNER_LOCK_FILE)) + await vi.waitFor(() => expect(getStructuredAgentSessionHost()).not.toBeNull(), { + timeout: 10_000 + }) }) function heldOnce(mock: ReturnType): () => void { diff --git a/src/main/runtime/structured-agent-session-journal-ownership.ts b/src/main/runtime/structured-agent-session-journal-ownership.ts index c53ca667e51d..030b1d075ccc 100644 --- a/src/main/runtime/structured-agent-session-journal-ownership.ts +++ b/src/main/runtime/structured-agent-session-journal-ownership.ts @@ -2,9 +2,9 @@ // // The claim is taken at runtime start, before `orca-runtime.json` is published, so a process that // does not own the chats never advertises itself as the one that does. A process that cannot take -// it serves every structured-chat request a refusal naming what to do, and keeps retrying: the -// owner quitting is what clears it. One claim per process; a claim on another state directory -// releases the previous one first. +// it, because another holds it or because the lock file will not open, serves every structured-chat +// request a refusal and keeps retrying; publishing waits for the retry to land. One claim per +// process; a claim on another state directory releases the previous one first. import { resolve } from 'node:path' import type { AgentSessionJournalProcessKind } from '../../shared/agent-session-refusal-details' @@ -32,7 +32,12 @@ export const JOURNAL_OWNER_REFUSAL_MESSAGES: Record void } } +type Refused = { + stateDirectory: string + retry: { cancel: () => void } + /** Why the latest attempt could not open the lock, or null while another process holds it. */ + failure: { error: unknown } | null +} let processKind: JournalOwnerProcessKind = 'packaged' let held: JournalOwnerLock | null = null @@ -44,7 +49,8 @@ export function setJournalOwnerProcessKind(kind: JournalOwnerProcessKind): void processKind = kind } -/** The held lock for `stateDirectory`, or null while another process holds it. */ +/** The held lock for `stateDirectory`, or null while another process holds it. Throws while the + * lock file will not open; either way the claim stays refused and retries. */ export function claimStructuredAgentSessionJournal( stateDirectory: string ): JournalOwnerLock | null { @@ -53,36 +59,61 @@ export function claimStructuredAgentSessionJournal( return held } if (refused?.stateDirectory === directory) { - return null + return refusedClaim(refused) } // A profile switch: the old claim goes before the new one is taken. releaseStructuredAgentSessionJournal() - const lock = tryAcquireJournalOwnerLock(directory) + const claim: Refused = { + stateDirectory: directory, + retry: { cancel: () => undefined }, + failure: null + } + // Every attempt, the retry's too, records why it failed: not opening the lock is not owning it. + const attempt = (lockDirectory: string): JournalOwnerLock | null => { + try { + const lock = tryAcquireJournalOwnerLock(lockDirectory) + claim.failure = null + return lock + } catch (error) { + claim.failure = { error } + return null + } + } + const lock = attempt(directory) if (lock) { adopt(lock) return lock } - console.warn( - `[structured-agent-session] another process owns ${directory}/${JOURNAL_OWNER_LOCK_FILE}; structured chats are read-refused in this process` - ) - refused = { + if (!claim.failure) { + console.warn( + `[structured-agent-session] another process owns ${directory}/${JOURNAL_OWNER_LOCK_FILE}; structured chats are read-refused in this process` + ) + } + claim.retry = retryJournalOwnerLock({ stateDirectory: directory, - retry: retryJournalOwnerLock({ - stateDirectory: directory, - onAcquired: adopt, - onError: (error) => - console.warn( - '[structured-agent-session] retrying the chat journal owner lock failed', - error - ) - }) + onAcquired: adopt, + acquire: attempt + }) + refused = claim + return refusedClaim(claim) +} + +function refusedClaim(claim: Refused): null { + if (claim.failure) { + throw claim.failure.error } return null } -/** What every structured-chat request gets while another process owns the chats. */ +/** Whether this process is waiting on its claim, whichever way it was refused. */ +export function isStructuredAgentSessionJournalClaimRefused(): boolean { + return refused !== null +} + +/** What every structured-chat request gets while another process owns the chats. A lock file + * that will not open refuses through the install instead, like the database it guards. */ export function structuredAgentSessionJournalOwnerRefusal(): AgentSessionRefusalError | null { - if (!refused) { + if (!refused || refused.failure) { return null } return agentSessionRefusalError( From 508839fdc50f06ba79437c68e5de3d34b1e07c0a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:11:50 -0700 Subject: [PATCH 37/66] fix(native-chat): keep a chat's history when an older build started it over The first copy deletes a chat's per-chat file, so an older build run after a downgrade finds no file and starts the chat from nothing. On the re-upgrade that fresh file was copied in as the newer history, replacing everything the shared database held for the chat, and then deleted. A file whose epoch is not the one last copied and that opens with `session_created` is now kept: neither copied nor deleted, and the chat keeps the history it has. A file that carried the copied epoch on is still copied again, as before. --- .../journal-per-session-import.test.ts | 109 +++++++++++++++--- .../journal-per-session-import.ts | 21 ++-- .../journal-per-session-reimport.ts | 21 +++- .../journal-per-session-source.ts | 17 ++- 4 files changed, 135 insertions(+), 33 deletions(-) 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 index 80a90cf14f75..af42ac01cbaa 100644 --- 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 @@ -1,6 +1,7 @@ // 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 the newer history, and is copied again. +// reappears after a downgrade and carried the history on is the newer history, and is copied +// again; one the older build started from nothing is left on disk, and the chat keeps its history. import type * as NodeFs from 'node:fs' import { existsSync, rmSync } from 'node:fs' @@ -510,10 +511,40 @@ describe('importing a per-chat journal', () => { expect(await leftovers()).toEqual([]) }) - // T-B5: a downgrade, an older build writing the chat's history to a new per-chat file, and a - // re-upgrade — twice. The newer history wins each time, says so, and each file is deleted. - it('copies the newer history an older build wrote, on every re-upgrade', async () => { - const first = await historyRows() + // 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') + expect(texts(reopened)).not.toContain('continued in an older version of Orca') + 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)) + }) + + // 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( @@ -522,21 +553,65 @@ describe('importing a per-chat journal', () => { { fence: 1 } ) await journals.closeAll() - - for (const [cycle, epoch] of ['epoch-downgrade-one', 'epoch-downgrade-two'].entries()) { - const older = await historyRows(epoch, `older build, cycle ${cycle + 1}`) - await writeLegacyJournal(older.epoch, older.rows) - 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(epoch) - expect(texts(reopened)).toContain(`older build, cycle ${cycle + 1}`) - expect(texts(reopened)).not.toContain('this build') - expect(texts(reopened)).toContain('continued in an older version of Orca') - expect(existsSync(legacyDir())).toBe(false) + 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(texts(reopened)).not.toContain('continued in an older version of Orca') + expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) await journals.closeAll() } - expect(await leftovers()).toEqual([]) + }) + + // The delete failed, so the older build found the copied file and carried its epoch on: that file + // is the newer history, copied again under the same epoch, with the row that says so. + it('copies again 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() + const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq: rows.length + 1 } + 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, rows.length + 1, 1, JSON.stringify(olderRow)) + legacy.close() + await removeWorks() + + const reopened = await openChat() + + expect(reopened.epoch).toBe(epoch) + expect(texts(reopened)).toContain('older') + expect(texts(reopened)).toContain('continued in an older version of Orca') + expect(existsSync(legacyDir())).toBe(false) }) // N-R3.1: the delete failed, this build appended, and an older build then appended to that same 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 index 18ddffd8b4a5..f3b012e4a7af 100644 --- 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 @@ -3,8 +3,8 @@ // 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 copied again (see -// journal-per-session-reimport.ts). +// same row after it. A file that reappears after a downgrade is copied again, unless the older +// build started it from nothing; that one is left on disk (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 into a block `journal_import_blocks` reserves, which no reader follows. Once the @@ -31,8 +31,7 @@ import { readPerSessionImportMarker, reimportedJournalRows, writePerSessionImportMarker, - type PerSessionImportPlan, - type PerSessionJournalHead + type PerSessionImportPlan } from './journal-per-session-reimport' import { foldLegacyJournal, @@ -43,6 +42,7 @@ import { readLegacyRepair, removeLegacyJournal, type ImportBatch, + type LegacyJournalHead, type ImportedRow } from './journal-per-session-source' import { parseJournalRow } from './journal-row-schema' @@ -73,7 +73,7 @@ export type PerSessionJournalImportDeps = { batchRows?: number } -export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' +export type PerSessionJournalImportOutcome = 'absent' | 'imported' | 'already-imported' | 'kept' type ImportInput = { database: JournalHostDatabase @@ -112,13 +112,13 @@ async function importOnce(input: ImportInput): Promise + legacy: LegacyJournalHead, + plan: Extract ): Promise { const { sessionId } = input.identity const epoch = plan.kind === 'again' ? plan.epoch : legacy.epoch 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 index c77f63636fd4..57e71343eaa6 100644 --- 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 @@ -1,12 +1,15 @@ // 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. That file is the newer history. +// downgrade, attached the chat and wrote its history there. A file that carried the chat's +// history on is the newer history. One the older build started from nothing, since the copy's +// delete left it no file, holds none of this build's history: it stays on disk as it is, and never +// replaces that history. // // `journal_imports` records which file each chat was copied from — its epoch and tip — in the // transaction that publishes the verified copy, so a file already copied (only its delete failed, // or a crash came first, across any number of restarts) is deleted, never copied again, and a -// file that differs always is. Newest writer -// wins, per chat, and the chat says so. When both builds advanced one epoch from the recorded -// tip, the copy takes a fresh epoch, so every reader resets instead of silently skipping rows. +// file that carried the history on always is. Newest writer wins, per chat, and the chat says so. +// When both builds advanced one epoch from the recorded tip, the copy takes a fresh epoch, so every +// reader resets instead of silently skipping rows. import { randomUUID } from 'node:crypto' import type Database from '../../sqlite/sync-database' @@ -15,6 +18,7 @@ import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow } from './journal-row-builders' import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' import { readJournalTip, type JournalBlockPointer } from './journal-row-table' +import type { LegacyJournalHead } from './journal-per-session-source' export type PerSessionJournalHead = { epoch: string; tip: number } @@ -51,6 +55,8 @@ export function writePerSessionImportMarker( export type PerSessionImportPlan = | { kind: 'first' } | { kind: 'copied' } + /** An older build started the file from nothing: kept on disk, neither copied nor deleted. */ + | { kind: 'kept' } /** The file is newer history: copied again, as `epoch`, with a row saying so. */ | { kind: 'again'; epoch: string } @@ -58,7 +64,7 @@ export type PerSessionImportPlan = export function planPerSessionImport(input: { db: Database.Database sessionId: string - legacy: PerSessionJournalHead + legacy: LegacyJournalHead current: JournalBlockPointer | null }): PerSessionImportPlan { const marker = readPerSessionImportMarker(input.db, input.sessionId) @@ -68,6 +74,11 @@ export function planPerSessionImport(input: { if (!marker && !input.current) { return { kind: 'first' } } + // This build holds the chat (a pointer or a marker), so a file an older build started from + // nothing is not its history: copying it would replace everything this build has. + if (input.legacy.startedFresh && input.legacy.epoch !== marker?.epoch) { + return { kind: 'kept' } + } // Both sides wrote past the recorded tip under one epoch: replacing it in place would leave a // reader at this build's tip skipping the older build's rows. const bothAdvanced = 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 index c53975e5f8a7..5211e7a92810 100644 --- 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 @@ -9,6 +9,7 @@ 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' +import { parseJournalRow } from './journal-row-schema' /** The newest per-chat file shape any build wrote. */ const LEGACY_JOURNAL_SCHEMA_VERSION = 2 @@ -20,6 +21,8 @@ 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 SELECT_LEGACY_FIRST_ROW = `SELECT row_json FROM journal_rows +WHERE session_id = ? AND epoch = ? ORDER BY seq ASC LIMIT 1` 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 = ?' @@ -42,10 +45,13 @@ export function openLegacySource(path: string): Database.Database { } } +/** The file's epoch and tip, and whether a build that found no history for the chat opened it. */ +export type LegacyJournalHead = PerSessionJournalHead & { startedFresh: boolean } + export function readLegacyHead( source: Database.Database, sessionId: string -): PerSessionJournalHead | null { +): LegacyJournalHead | 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 @@ -55,7 +61,14 @@ export function readLegacyHead( return null } const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip - return { epoch, tip: typeof tip === 'number' ? tip : 0 } + const first = source.prepare(SELECT_LEGACY_FIRST_ROW).get(sessionId, epoch)?.row_json + const opened = typeof first === 'string' ? parseJournalRow(first) : null + return { + epoch, + tip: typeof tip === 'number' ? tip : 0, + startedFresh: + opened?.ok === true && opened.row.kind === 'epoch' && opened.row.reason === 'session_created' + } } /** The file's rows, one bounded page per batch, read as each batch is written. */ From b50fab86a7a717a5fa33614d4f515de35436c2c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:16:27 -0700 Subject: [PATCH 38/66] test(native-chat): pin which chats startup restore copies Restore copies a chat still in its per-chat file only when restore itself has to write to it: settling what the last run left open, here a running tool call or a send handed over and never answered. Every other restored chat stays in its file until its first use. --- ...ent-session-restore-without-import.test.ts | 71 +++++++++++++++---- 1 file changed, 57 insertions(+), 14 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 8e0f5e411b86..2637d4dd990c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -1,6 +1,7 @@ // Startup restore lists a chat that is still in its per-chat file from a read-only fold of that -// file, and copies nothing: the copy waits for the chat's first real read or write. Restore stays -// the cost it was when every chat had its own file, and opens no file it does not restore. +// file, and copies nothing: the copy waits for the chat's first real read or write, which is +// restore's own only for a chat the last run left mid-work. Restore stays the cost it was when +// every chat had its own file, and opens no file it does not restore. import { existsSync } from 'node:fs' import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' @@ -107,8 +108,15 @@ function legacyDirFor(sessionId: string): string { return journalDirectoryFor(root, { workspaceId: WORKSPACE_ID, sessionId }) } +/** What the run before the upgrade left open in a chat, for its restore to settle. */ +type MidWork = 'running tool call' | 'unresolved send' + /** A chat as an earlier build left it: real rows in its own per-chat file, nothing in the host's. */ -async function seedLegacyChat(sessionId: string, replies = 1): Promise { +async function seedLegacyChat( + sessionId: string, + replies = 1, + midWork?: MidWork +): Promise { const scratch = join(root, `scratch-${sessionId}`) const identity = identityFor(sessionId) const journal = await openAgentSessionJournal({ @@ -123,17 +131,29 @@ async function seedLegacyChat(sessionId: string, replies = 1): Promise { ) }) + // Restore copies a chat only to write to it itself, settling what the last run left open (a + // turn, tool call, approval, question, send or subagent). A settled chat is never copied here. + it.each(['running tool call', 'unresolved send'] as const)( + 'copies during restore only a chat it settles (%s)', + async (midWork) => { + const rows = await seedLegacyChat('chat-mid-work', 1, midWork) + await seedLegacyChat('chat-settled') + + const { sessions } = await restore(['chat-mid-work', 'chat-settled']) + + // Restore wrote a settlement to the chat left mid-work, so it copied that chat first. + const settled = sessions.get('chat-mid-work')!.journal + expect(settled.cursor().sequence).toBeGreaterThan(rows.length) + expect( + readTestJournalRows(hostDb(), 'chat-mid-work', rows[0]!.epoch).slice(0, rows.length) + ).toEqual(rows) + expect(existsSync(legacyFile('chat-mid-work'))).toBe(false) + expect(readJournalSessionPointer(hostDb(), 'chat-settled')).toBeNull() + expect(existsSync(legacyFile('chat-settled'))).toBe(true) + expect(importCount()).toBe(1) + } + ) + it('lets other work run while it reads a large per-chat file', async () => { // Past one batch of the file's rows. const rows = await seedLegacyChat('chat-a', 520) From 600710f2c3a49156c91429672016b2f0dfbde754 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:18:07 -0700 Subject: [PATCH 39/66] test(native-chat): pin the copy wait on a read that opens a chat restore opened A read queued behind restore's open of the same chat reaches the conversation through its own open rather than the listing. It must still wait for the owed copy, or it reads the chat before its history is in the one database. --- ...ent-session-restore-without-import.test.ts | 47 ++++++++++++++----- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 2637d4dd990c..370820f084d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -204,12 +204,18 @@ function importCount(): number { return Number(hostDb().prepare('SELECT count(*) AS n FROM journal_imports').get()?.n) } -async function restore(sessionIds: readonly string[]) { - const sessions = new StructuredAgentSessionConversations({ +type LifetimeHost = Parameters[0] + +function conversations(): StructuredAgentSessionConversations { + return new StructuredAgentSessionConversations({ deliver: () => undefined, onDeliveryError: () => undefined, now: () => clock }) +} + +async function restore(sessionIds: readonly string[]) { + const sessions = conversations() // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restore and an open conversation's read touch only `getRecord` and `listRecords`. const store = { getRecord: (sessionId: string) => recordFor(sessionId), @@ -227,16 +233,20 @@ async function restore(sessionIds: readonly string[]) { sessions.set(sessionId, opened.session) } }) - const lifetime = createStructuredAgentSessionConversationLifetime({ + const context = (): StructuredAgentSessionLifetimeContext => // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: reaching an open conversation reads only `deps.store` and `deps.adapter`. - context: () => ({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext, - sessions, - serialize: async (_sessionId, task) => task(), - open: async (sessionId) => sessions.get(sessionId) ?? null, - deliveryActive: () => false, - closeStatus: () => undefined - }) - return { sessions, lifetime } + ({ deps, now: () => clock }) as unknown as StructuredAgentSessionLifetimeContext + const lifetimeOver = (listed: LifetimeHost['sessions'], open: LifetimeHost['open']) => + createStructuredAgentSessionConversationLifetime({ + context, + sessions: listed, + serialize: async (_sessionId, task) => task(), + open, + deliveryActive: () => false, + closeStatus: () => undefined + }) + const lifetime = lifetimeOver(sessions, async (sessionId) => sessions.get(sessionId) ?? null) + return { sessions, lifetime, lifetimeOver } } function texts(items: readonly { body: unknown }[]): string { @@ -390,6 +400,21 @@ describe('startup restore of chats still in their per-chat files', () => { expect(existsSync(legacyDirFor('chat-a'))).toBe(false) }) + // A read queued behind restore's open of the same chat reaches it through its own open, not the + // listing: it still waits for the copy, and reads the chat from the one database. + it('makes a read whose open lands on the chat restore opened wait for its copy', async () => { + const rows = await seedLegacyChat('chat-a') + const { sessions, lifetimeOver } = await restore(['chat-a']) + const restored = sessions.get('chat-a')! + const lifetime = lifetimeOver(conversations(), async () => restored) + + const { journal } = await lifetime.conversation('chat-a') + const since = journal.readSince({ epoch: rows[0]!.epoch, sequence: 0 }) + + expect(importCount()).toBe(1) + expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) + }) + it('copies a chat before its first write, and the write lands after its history', async () => { const rows = await seedLegacyChat('chat-a') const { sessions } = await restore(['chat-a']) From ff4ae989f51ff08455e23b6558e443cb4ad85f60 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:22:33 -0700 Subject: [PATCH 40/66] fix(native-chat): record a set-aside per-chat file so no later open reads it Setting aside a file an older build started over is decided once and kept in the new `journal_set_aside` table (schema 2, additive), with the file's epoch and tip as they were. Every later open of the chat skips the file without opening it, across restarts and after the older build writes more to it: anything written there grows from that build's own start, never from this build's history. The best-effort delete moves beside the per-chat file reader. --- .../journal-database-schema.ts | 12 ++++-- .../journal-per-session-import.test.ts | 39 +++++++++++++++++++ .../journal-per-session-import.ts | 24 ++++++------ .../journal-per-session-reimport.ts | 22 ++++++++++- .../journal-per-session-source.ts | 16 +++++--- 5 files changed, 90 insertions(+), 23 deletions(-) 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 b5acf50c6ed9..c61855e2e81e 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 @@ -5,14 +5,15 @@ // 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 last copied from (journal-per-session-reimport.ts); `journal_import_blocks` the block a copy -// still in progress writes into, which no reader follows. `status_json` is the chat's last settled +// was last copied from (journal-per-session-reimport.ts), and `journal_set_aside` each chat whose +// per-chat file an older build started over and which is never read again; `journal_import_blocks` +// the block a copy still in progress writes into, which no reader follows. `status_json` is the chat's last settled // listing status, trusted only where `status_seq` is still the tip of the live epoch. /** 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 latches this build * out, so every change stays additive. */ -export const JOURNAL_DB_SCHEMA_VERSION = 1 +export const JOURNAL_DB_SCHEMA_VERSION = 2 export function createJournalTablesSql(): string { return ` @@ -40,6 +41,11 @@ CREATE TABLE IF NOT EXISTS journal_imports ( 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 +); CREATE TABLE IF NOT EXISTS journal_import_blocks ( session_id TEXT PRIMARY KEY, block INTEGER NOT NULL UNIQUE 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 index af42ac01cbaa..cddfb1f71e59 100644 --- 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 @@ -541,6 +541,45 @@ describe('importing a per-chat journal', () => { expect(keptRows.map((row) => row.row_json)).toEqual(older.rows.map((row) => row.rowJson)) }) + // 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 () => { 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 index f3b012e4a7af..52a1516c029e 100644 --- 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 @@ -4,7 +4,8 @@ // 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 copied again, unless the older -// build started it from nothing; that one is left on disk (see journal-per-session-reimport.ts). +// build started it from nothing; that one 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 into a block `journal_import_blocks` reserves, which no reader follows. Once the @@ -28,8 +29,10 @@ import { JournalImportMismatchError, journalOpenRefusalError } from './journal-o import { legacyJournalDatabaseFile } from './journal-paths' import { planPerSessionImport, + isPerSessionJournalSetAside, readPerSessionImportMarker, reimportedJournalRows, + setAsidePerSessionJournal, writePerSessionImportMarker, type PerSessionImportPlan } from './journal-per-session-reimport' @@ -40,7 +43,7 @@ import { openLegacySource, readLegacyHead, readLegacyRepair, - removeLegacyJournal, + retireLegacyJournal, type ImportBatch, type LegacyJournalHead, type ImportedRow @@ -110,6 +113,9 @@ async function importOnce(input: ImportInput): Promise void = (path) => rmSync(path, { force: true }) ): void { const file = legacyJournalDatabaseFile(legacyDirectory) - // 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 copied again as newer history. - for (const path of [file, `${file}-wal`, `${file}-shm`]) { - remove(path) + 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 copied again as newer history. + 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) From 654e9bf24c5933d14ec5ac3647fb4d25f1954d0f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:25:58 -0700 Subject: [PATCH 41/66] fix(native-chat): set aside any per-chat file at an epoch this build never copied A chat's per-chat file is deleted once its copy verifies, so a file that reappears at another epoch was never this build's history, whatever its first row says: an older build started the chat over, possibly rewinding it after (`handle_forked`), or rolled the epoch of a file whose delete had failed. Copying any of them would replace everything the chat holds, so each is set aside. Only a file still at the copied epoch is copied again (it grew) or deleted (it did not). The first-row check is gone. --- .../journal-per-session-import.test.ts | 51 ++++++++++++++++++- .../journal-per-session-import.ts | 12 ++--- .../journal-per-session-reimport.ts | 33 ++++++------ .../journal-per-session-source.ts | 17 +------ 4 files changed, 73 insertions(+), 40 deletions(-) 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 index cddfb1f71e59..3427f7bd880e 100644 --- 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 @@ -1,7 +1,7 @@ // 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 and carried the history on is the newer history, and is copied -// again; one the older build started from nothing is left on disk, and the chat keeps its history. +// reappears after a downgrade and carried the copied epoch on is the newer history, and is copied +// again; one at any other epoch is set aside on disk, and the chat keeps its history. import type * as NodeFs from 'node:fs' import { existsSync, rmSync } from 'node:fs' @@ -541,6 +541,53 @@ describe('importing a per-chat journal', () => { 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) + }) + // 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 () => { 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 index 52a1516c029e..efe8ace20851 100644 --- 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 @@ -3,8 +3,8 @@ // 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 copied again, unless the older -// build started it from nothing; that one is set aside, never read again (see +// same row after it. A file that reappears after a downgrade is copied again only when it carried +// the copied history on; any other 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. @@ -34,7 +34,8 @@ import { reimportedJournalRows, setAsidePerSessionJournal, writePerSessionImportMarker, - type PerSessionImportPlan + type PerSessionImportPlan, + type PerSessionJournalHead } from './journal-per-session-reimport' import { foldLegacyJournal, @@ -45,7 +46,6 @@ import { readLegacyRepair, retireLegacyJournal, type ImportBatch, - type LegacyJournalHead, type ImportedRow } from './journal-per-session-source' import { parseJournalRow } from './journal-row-schema' @@ -118,7 +118,7 @@ async function importOnce(input: ImportInput): Promise ): Promise { const { sessionId } = input.identity 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 index 3bb0bc5824d2..b3ca77c9bfbc 100644 --- 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 @@ -1,16 +1,19 @@ // 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. A file that carried the chat's -// history on is the newer history. One the older build started from nothing, since the copy's -// delete left it no file, holds none of this build's history: it stays on disk as it is, and never -// replaces that history. That is decided once and recorded in `journal_set_aside`, and the file is -// never opened again: whatever an older build writes there later grows from its own start. +// 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, so a file already copied (only its delete failed, -// or a crash came first, across any number of restarts) is deleted, never copied again, and a -// file that carried the history on always is. Newest writer wins, per chat, and the chat says so. -// When both builds advanced one epoch from the recorded tip, the copy takes a fresh epoch, so every -// reader resets instead of silently skipping rows. +// 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. A file at that epoch past it carried the copied history on: it is +// the newer history, copied again, and the chat says so. When both builds advanced one epoch from +// the recorded tip, the copy takes a fresh epoch, so every reader resets instead of silently +// skipping rows. +// +// A file at any other epoch, or one beside a chat this build founded itself, never held this +// build's history: an older build started it over (the copy deleted what it had), or rolled the +// epoch of a file it kept, and either way copying it would replace everything this build has. It +// is set aside, left on disk as it is, and recorded in `journal_set_aside`, so no later open reads +// it again: whatever an older build writes there later grows from that same file. import { randomUUID } from 'node:crypto' import type Database from '../../sqlite/sync-database' @@ -19,7 +22,6 @@ import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow } from './journal-row-builders' import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' import { readJournalTip, type JournalBlockPointer } from './journal-row-table' -import type { LegacyJournalHead } from './journal-per-session-source' export type PerSessionJournalHead = { epoch: string; tip: number } @@ -73,7 +75,7 @@ export function writePerSessionImportMarker( export type PerSessionImportPlan = | { kind: 'first' } | { kind: 'copied' } - /** An older build started the file from nothing: set aside, neither copied nor deleted. */ + /** Not this build's history: set aside, neither copied nor deleted. */ | { kind: 'kept' } /** The file is newer history: copied again, as `epoch`, with a row saying so. */ | { kind: 'again'; epoch: string } @@ -82,7 +84,7 @@ export type PerSessionImportPlan = export function planPerSessionImport(input: { db: Database.Database sessionId: string - legacy: LegacyJournalHead + legacy: PerSessionJournalHead current: JournalBlockPointer | null }): PerSessionImportPlan { const marker = readPerSessionImportMarker(input.db, input.sessionId) @@ -92,9 +94,7 @@ export function planPerSessionImport(input: { if (!marker && !input.current) { return { kind: 'first' } } - // This build holds the chat (a pointer or a marker), so a file an older build started from - // nothing is not its history: copying it would replace everything this build has. - if (input.legacy.startedFresh && input.legacy.epoch !== marker?.epoch) { + if (input.legacy.epoch !== marker?.epoch) { return { kind: 'kept' } } // Both sides wrote past the recorded tip under one epoch: replacing it in place would leave a @@ -102,7 +102,6 @@ export function planPerSessionImport(input: { const bothAdvanced = marker !== null && input.current?.epoch === marker.epoch && - input.legacy.epoch === marker.epoch && input.legacy.tip > marker.tip && readJournalTip(input.db, input.current.block) > marker.tip return { kind: 'again', epoch: bothAdvanced ? randomUUID() : input.legacy.epoch } 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 index 510ff4d2fc43..e865b79d7816 100644 --- 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 @@ -9,7 +9,6 @@ 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' -import { parseJournalRow } from './journal-row-schema' /** The newest per-chat file shape any build wrote. */ const LEGACY_JOURNAL_SCHEMA_VERSION = 2 @@ -21,8 +20,6 @@ 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 SELECT_LEGACY_FIRST_ROW = `SELECT row_json FROM journal_rows -WHERE session_id = ? AND epoch = ? ORDER BY seq ASC LIMIT 1` 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 = ?' @@ -45,13 +42,10 @@ export function openLegacySource(path: string): Database.Database { } } -/** The file's epoch and tip, and whether a build that found no history for the chat opened it. */ -export type LegacyJournalHead = PerSessionJournalHead & { startedFresh: boolean } - export function readLegacyHead( source: Database.Database, sessionId: string -): LegacyJournalHead | null { +): 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 @@ -61,14 +55,7 @@ export function readLegacyHead( return null } const tip = source.prepare(SELECT_LEGACY_TIP).get(sessionId, epoch)?.tip - const first = source.prepare(SELECT_LEGACY_FIRST_ROW).get(sessionId, epoch)?.row_json - const opened = typeof first === 'string' ? parseJournalRow(first) : null - return { - epoch, - tip: typeof tip === 'number' ? tip : 0, - startedFresh: - opened?.ok === true && opened.row.kind === 'epoch' && opened.row.reason === 'session_created' - } + return { epoch, tip: typeof tip === 'number' ? tip : 0 } } /** The file's rows, one bounded page per batch, read as each batch is written. */ From 6a5ee544c084572c0bbf8d5c50a55884bc1f9aac Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:07:07 -0700 Subject: [PATCH 42/66] fix(native-chat): copy a reappearing per-chat file again only while this build has not written past the copy A per-chat file that an older build carried on under the copied epoch was copied again even when this build had also written to the chat since the copy, or had rolled its epoch. The second copy replaced the chat's block, so what was sent in this build after the copy was gone for good. Now the file is copied again only when the chat still stands exactly as it was copied: the same epoch and tip the import marker recorded. Otherwise it is set aside like any other file that is not this build's history, left on disk untouched and recorded so no later open reads it. A second copy therefore never replaces rows this build wrote, keeps the file's own epoch, and the fresh-epoch rewrite goes away. The row it adds now says the history includes what the older version recorded, not that anything was replaced. --- .../journal-per-session-import.test.ts | 111 ++++++++++++------ .../journal-per-session-import.ts | 21 ++-- .../journal-per-session-reimport.ts | 63 ++++------ .../journal-per-session-source.ts | 2 +- 4 files changed, 111 insertions(+), 86 deletions(-) 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 index 3427f7bd880e..6c5a7e183223 100644 --- 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 @@ -1,7 +1,7 @@ // 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 and carried the copied epoch on is the newer history, and is copied -// again; one at any other epoch is set aside on disk, and the chat keeps its history. +// reappears after a downgrade and carried the copied epoch on is copied again while this build has +// not written past the copy; any other is set aside on disk, and the chat keeps its history. import type * as NodeFs from 'node:fs' import { existsSync, rmSync } from 'node:fs' @@ -669,8 +669,9 @@ describe('importing a per-chat journal', () => { } }) - // The delete failed, so the older build found the copied file and carried its epoch on: that file - // is the newer history, copied again under the same epoch, with the row that says so. + // The delete failed, so the older build found the copied file and carried its epoch on, while + // this build wrote nothing past the copy: the file is the copied history plus the older build's + // rows, copied again under the same epoch, with the row that says so. it('copies again a file an older build carried on under the copied epoch', async () => { const { epoch, rows } = await historyRows() await writeLegacyJournal(epoch, rows) @@ -678,7 +679,20 @@ describe('importing a per-chat journal', () => { vi.spyOn(console, 'warn').mockImplementation(() => undefined) await openChat() await journals.closeAll() - const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq: rows.length + 1 } + appendOlderRow(epoch, rows, rows.length + 1) + await removeWorks() + + const reopened = await openChat() + + expect(reopened.epoch).toBe(epoch) + expect(texts(reopened)).toContain('older') + expect(texts(reopened)).toContain('continued in an older version of Orca') + expect(existsSync(legacyDir())).toBe(false) + }) + + /** 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', @@ -688,22 +702,20 @@ describe('importing a per-chat journal', () => { const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) legacy .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, epoch, rows.length + 1, 1, JSON.stringify(olderRow)) + .run(IDENTITY.sessionId, epoch, seq, 1, JSON.stringify(olderRow)) legacy.close() - await removeWorks() - - const reopened = await openChat() + } - expect(reopened.epoch).toBe(epoch) - expect(texts(reopened)).toContain('older') - expect(texts(reopened)).toContain('continued in an older version of Orca') - expect(existsSync(legacyDir())).toBe(false) - }) + 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 + } // N-R3.1: the delete failed, this build appended, and an older build then appended to that same - // file. Both advanced from the recorded tip under one epoch, so the copy takes a fresh one: a - // reader at this build's tip resets instead of silently skipping the older build's rows. - it('gives the copy a fresh epoch when both builds wrote past the same recorded tip', async () => { + // file. Copying the file again would replace what this build wrote, so it is set aside. + it('keeps this build’s history, and the older file untouched, when both builds wrote past the copy', async () => { const { epoch, rows } = await historyRows() await writeLegacyJournal(epoch, rows) removeFails() @@ -715,29 +727,56 @@ describe('importing a per-chat journal', () => { { fence: 1 } ) await journals.closeAll() - const olderRow = { ...JSON.parse(rows.at(-1)!.rowJson), seq: rows.length + 1 } - olderRow.body = { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'older' }] + appendOlderRow(epoch, rows, rows.length + 1) + const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) + await removeWorks() + + const reopened = await openChat() + + expect(reopened.epoch).toBe(epoch) + expect(texts(reopened)).toContain('this build') + expect(texts(reopened)).not.toContain('older') + expect(texts(reopened)).not.toContain('continued in an older version of Orca') + expect(sharedRowsContaining('this build')).toBeGreaterThan(0) + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) + }) + + // This build rolled its epoch after the copy (a rewind), and the older build then carried the + // leftover file on under the copied epoch: the file no longer holds what this build has. The new + // epoch restarts its sequence, so this build's tip is brought level with the recorded one. + it('keeps this build’s history when it rolled its epoch after the copy', async () => { + const { epoch, rows } = await historyRows() + await writeLegacyJournal(epoch, rows) + removeFails() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const journal = await openChat() + let cursor = await journal.rollEpoch('handle_forked', 1) + for (let ordinal = 3; cursor.sequence < rows.length; ordinal += 1) { + const appended = await journal.appendItem( + item(ordinal), + { + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text: 'after the rewind' }] + }, + { fence: 1 } + ) + cursor = appended.cursor } - olderRow.itemId = `${olderRow.itemId}-older` - const legacy = new Database(legacyJournalDatabaseFile(legacyDir())) - legacy - .prepare('INSERT INTO journal_rows VALUES (?, ?, ?, ?, ?)') - .run(IDENTITY.sessionId, epoch, rows.length + 1, 1, JSON.stringify(olderRow)) - legacy.close() + expect(cursor.sequence).toBe(rows.length) + const rolledEpoch = journal.epoch + await journals.closeAll() + appendOlderRow(epoch, rows, rows.length + 1) + const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) await removeWorks() const reopened = await openChat() - expect(reopened.epoch).not.toBe(epoch) - expect(texts(reopened)).toContain('older') - expect(texts(reopened)).toContain('continued in an older version of Orca') - expect(reopened.readSince({ epoch, sequence: rows.length + 1 })).toEqual({ - ok: false, - reset: 'epoch_changed' - }) - expect(existsSync(legacyDir())).toBe(false) + expect(rolledEpoch).not.toBe(epoch) + expect(reopened.epoch).toBe(rolledEpoch) + expect(texts(reopened)).toContain('after the rewind') + expect(texts(reopened)).not.toContain('older') + expect(texts(reopened)).not.toContain('continued in an older version of Orca') + expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) }) }) 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 index efe8ace20851..e29232d65c7d 100644 --- 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 @@ -4,8 +4,8 @@ // 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 copied again only when it carried -// the copied history on; any other is set aside, never read again (see -// journal-per-session-reimport.ts). +// the copied history on and this build has not written past the copy; any other 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 into a block `journal_import_blocks` reserves, which no reader follows. Once the @@ -207,15 +207,14 @@ async function copyLegacyJournal( plan: Extract ): Promise { const { sessionId } = input.identity - const epoch = plan.kind === 'again' ? plan.epoch : legacy.epoch + const { epoch } = legacy const repair = readLegacyRepair(source, sessionId) const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - // A second copy is read whole: it is rewritten under a fresh epoch or gains a disclosure row. - const rewritten = + // A second copy is read whole, to follow its rows with the disclosure row. + const reimported = plan.kind === 'again' ? reimportedJournalRows({ sessionId, - legacyEpoch: legacy.epoch, epoch, rows: [...legacyRowBatches(source, sessionId, legacy.epoch, batchRows)].flatMap( (batch) => batch.rows @@ -223,8 +222,8 @@ async function copyLegacyJournal( now: (input.now ?? Date.now)() }) : null - const batches = rewritten - ? arrayBatches(rewritten, batchRows) + const batches = reimported + ? arrayBatches(reimported, batchRows) : legacyRowBatches(source, sessionId, legacy.epoch, batchRows) let block: number | null = null for (const batch of batches) { @@ -245,8 +244,8 @@ async function copyLegacyJournal( const target = block ?? input.database.transaction((db) => reserveImportBlock(db, sessionId)) await verifyCopiedJournal( input, - rewritten - ? arrayBatches(rewritten, batchRows) + reimported + ? arrayBatches(reimported, batchRows) : legacyRowBatches(source, sessionId, legacy.epoch, batchRows), { epoch, block: target } ) @@ -260,7 +259,7 @@ async function copyLegacyJournal( if (repair) { db.prepare(UPSERT_REPAIR).run( sessionId, - repair.epoch === legacy.epoch ? epoch : repair.epoch, + repair.epoch, repair.content_from, repair.repaired_at ) 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 index b3ca77c9bfbc..ffcc6050368a 100644 --- 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 @@ -4,23 +4,21 @@ // `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. A file at that epoch past it carried the copied history on: it is -// the newer history, copied again, and the chat says so. When both builds advanced one epoch from -// the recorded tip, the copy takes a fresh epoch, so every reader resets instead of silently -// skipping rows. +// deleted, never copied again. A file at that epoch past it carried the copied history on, and +// while this build's history still stands exactly as copied, the file is that history plus the +// older build's rows: it is copied again, and the chat says so. // -// A file at any other epoch, or one beside a chat this build founded itself, never held this -// build's history: an older build started it over (the copy deleted what it had), or rolled the -// epoch of a file it kept, and either way copying it would replace everything this build has. It -// is set aside, left on disk as it is, and recorded in `journal_set_aside`, so no later open reads -// it again: whatever an older build writes there later grows from that same file. +// Anything else would replace history this build holds: a file at any other epoch, or beside a +// chat this build founded itself, never held it (an older build started it over, or rolled the +// epoch of a file it kept); and once this build has written past the copy, or rolled its own +// epoch, the file no longer carries what this build has. Such a file is set aside, left on disk as +// it is, and recorded in `journal_set_aside`, so no later open reads it again. -import { randomUUID } from 'node:crypto' import type Database from '../../sqlite/sync-database' import { boundJournalStatusText } from './journal-prompt-body-bounds' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow } from './journal-row-builders' -import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' +import { parseJournalRow, serializeJournalRow } from './journal-row-schema' import { readJournalTip, type JournalBlockPointer } from './journal-row-table' export type PerSessionJournalHead = { epoch: string; tip: number } @@ -35,7 +33,7 @@ export const JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY = { } as const const OLDER_BUILD_DISCLOSURE = - 'This chat was continued in an older version of Orca. Its history now comes from that version; anything this version recorded before then was replaced.' + 'This chat was continued in an older version of Orca. Its history includes what was recorded there.' export function readPerSessionImportMarker( db: Database.Database, @@ -55,7 +53,7 @@ export function isPerSessionJournalSetAside(db: Database.Database, sessionId: st return db.prepare(SELECT_SET_ASIDE).get(sessionId) !== undefined } -/** Records the file an older build started over, as it was when set aside. */ +/** Records a file that is not this build's history, as it was when set aside. */ export function setAsidePerSessionJournal( db: Database.Database, sessionId: string, @@ -77,8 +75,8 @@ export type PerSessionImportPlan = | { kind: 'copied' } /** Not this build's history: set aside, neither copied nor deleted. */ | { kind: 'kept' } - /** The file is newer history: copied again, as `epoch`, with a row saying so. */ - | { kind: 'again'; epoch: string } + /** The copied history carried on: copied again, with a row saying so. */ + | { kind: 'again' } /** What a present per-chat file owes this chat, judged against what was last copied from it. */ export function planPerSessionImport(input: { @@ -94,43 +92,32 @@ export function planPerSessionImport(input: { if (!marker && !input.current) { return { kind: 'first' } } - if (input.legacy.epoch !== marker?.epoch) { - return { kind: 'kept' } - } - // Both sides wrote past the recorded tip under one epoch: replacing it in place would leave a - // reader at this build's tip skipping the older build's rows. - const bothAdvanced = + // Copied again only while this build's history is exactly what was copied: past that, the copy + // would replace rows this build wrote. + const continued = marker !== null && + input.legacy.epoch === marker.epoch && input.current?.epoch === marker.epoch && - input.legacy.tip > marker.tip && - readJournalTip(input.db, input.current.block) > marker.tip - return { kind: 'again', epoch: bothAdvanced ? randomUUID() : input.legacy.epoch } + readJournalTip(input.db, input.current.block) === marker.tip + return continued ? { kind: 'again' } : { kind: 'kept' } } -/** - * The rows a second copy writes: the file's rows under `epoch` — byte-identical when the epoch is - * the file's own — then the row that says the chat continued in an older Orca. - */ +/** The rows a second copy writes: the file's rows as stored, then the row that says the chat + * continued in an older Orca. */ export function reimportedJournalRows(input: { sessionId: string - legacyEpoch: string epoch: string rows: readonly { seq: number; ts: number; rowJson: string }[] now: number }): { seq: number; ts: number; rowJson: string }[] { const state = createJournalReducerState(input.sessionId, input.epoch) - const copied = input.rows.map((stored) => { + for (const stored of input.rows) { const parsed = parseJournalRow(stored.rowJson) if (!parsed.ok) { throw new Error(`per-chat journal row ${stored.seq} of ${input.sessionId} is unreadable`) } - const row: JournalRow = - input.epoch === input.legacyEpoch ? parsed.row : { ...parsed.row, epoch: input.epoch } - applyJournalRow(state, row) - return input.epoch === input.legacyEpoch - ? stored - : { seq: stored.seq, ts: stored.ts, rowJson: serializeJournalRow(row) } - }) + applyJournalRow(state, parsed.row) + } const disclosure = buildJournalItemRow({ state, identity: JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY, @@ -140,7 +127,7 @@ export function reimportedJournalRows(input: { ts: input.now }) return [ - ...copied, + ...input.rows, { seq: disclosure.seq, ts: disclosure.ts, rowJson: serializeJournalRow(disclosure) } ] } 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 index e865b79d7816..edc793c3ed91 100644 --- 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 @@ -128,7 +128,7 @@ export function retireLegacyJournal( 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 copied again as newer history. + // would read back short of the tip it was copied at, and be copied again. for (const path of [file, `${file}-wal`, `${file}-shm`]) { remove(path) } From 74f4867ad54148efa32fbd6899cd8bbee6c855a2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:08:49 -0700 Subject: [PATCH 43/66] test(native-chat): pin that a chat founded here keeps its history, and the v1 schema upgrade A chat this build founded has a pointer and no import marker, so a per-chat file an older build later starts for it is set aside. Nothing pinned that half of the rule: letting such a chat be copied again replaced its history and every test still passed. A second test pins that a database written at schema version 1 upgrades in place, gaining the set-aside table and keeping its import markers. --- .../journal-database-schema.ts | 2 +- .../journal-database.test.ts | 33 +++++++++++++++++++ .../journal-per-session-import.test.ts | 23 +++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) 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 c61855e2e81e..6c1b40a74e16 100644 --- a/src/main/native-chat/agent-session-journal/journal-database-schema.ts +++ b/src/main/native-chat/agent-session-journal/journal-database-schema.ts @@ -6,7 +6,7 @@ // 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 last copied from (journal-per-session-reimport.ts), and `journal_set_aside` each chat whose -// per-chat file an older build started over and which is never read again; `journal_import_blocks` +// per-chat file is not this build's history and is never read again; `journal_import_blocks` // the block a copy still in progress writes into, which no reader follows. `status_json` is the chat's last settled // listing status, trusted only where `status_seq` is still the tip of the live epoch. 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 4f6e8c5f2a3d..2789dbf9a455 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 @@ -240,4 +240,37 @@ describe('schema creation', () => { inspected.close() } }) + + // A database an earlier head of this schema wrote gains the set-aside table and keeps its rows. + it('upgrades a version 1 database in place', () => { + const v1 = new Database(dbPath) + v1.pragma('auto_vacuum = INCREMENTAL') + v1.pragma('journal_mode = WAL') + v1.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); +CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, + content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL); +CREATE TABLE journal_imports (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, tip INTEGER NOT NULL); +CREATE TABLE journal_import_blocks (session_id TEXT PRIMARY KEY, block INTEGER NOT NULL UNIQUE); +INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL); +INSERT INTO journal_imports VALUES ('s1', 'e0', 3);`) + v1.pragma('user_version = 1') + v1.close() + + const db = openJournalDatabase(dbPath) + try { + expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) + expect( + db.prepare("SELECT name FROM sqlite_master WHERE name = 'journal_set_aside'").get() + ).toBeTruthy() + expect(db.prepare('SELECT session_id, epoch, tip FROM journal_imports').all()).toEqual([ + { session_id: 's1', epoch: 'e0', tip: 3 } + ]) + expect(journalPragmaNumber(db, 'auto_vacuum')).toBe(2) + } finally { + db.close() + } + }) }) 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 index 6c5a7e183223..64c577f79eca 100644 --- 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 @@ -588,6 +588,29 @@ describe('importing a per-chat journal', () => { 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 } + ) + 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 () => { From b156850e366e6270f460aefe4158261ab03afdea Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:46:11 -0700 Subject: [PATCH 44/66] test(native-chat): drop a lost copied row by patching the source, not wrapping it --- .../journal-per-session-import.test.ts | 43 ++++++++----------- 1 file changed, 18 insertions(+), 25 deletions(-) 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 index 64c577f79eca..aef1c27dacdd 100644 --- 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 @@ -142,33 +142,26 @@ async function removeWorks(): Promise { /** The file as it is, except that the copy's first read of rows loses the first one. */ function losingFirstCopiedRow(path: string): Database.Database { const source = new Database(path, { readonly: true, fileMustExist: true }) + const prepare = source.prepare.bind(source) let lost = false - return new Proxy(source, { - get(target, key) { - if (key === 'prepare') { - return (sql: string) => { - const statement = target.prepare(sql) - if (lost || !sql.includes('seq > ?')) { - return statement - } - return new Proxy(statement, { - get(inner, name) { - if (name === 'all') { - return (...args: Parameters) => { - lost = true - return inner.all(...args).slice(1) - } - } - const value = Reflect.get(inner, name) - return typeof value === 'function' ? value.bind(inner) : value - } - }) - } + source.prepare = (sql: string) => { + const statement = prepare(sql) + if (lost || !sql.includes('seq > ?')) { + return statement + } + const all = statement.all.bind(statement) + // Why: prepare caches statements, so this one is handed out again after it has lost its row. + statement.all = (...args: Parameters) => { + const rows = all(...args) + if (lost) { + return rows } - const value = Reflect.get(target, key) - return typeof value === 'function' ? value.bind(target) : value + lost = true + return rows.slice(1) } - }) + return statement + } + return source } function openChat() { @@ -499,7 +492,7 @@ describe('importing a per-chat journal', () => { db.close() } ] - ])('opens a chat whose per-chat file is %s as having no history', async (_shape, create) => { + ])('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())) From 7ca760b0b4e8634373407016d0c9d9a790a2f45f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:46:16 -0700 Subject: [PATCH 45/66] chore(mobile): restore the mobile lockfile to main's --- mobile/pnpm-lock.yaml | 98 ++++++++++++++++--------------------------- 1 file changed, 37 insertions(+), 61 deletions(-) diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 909cd9c17491..f55a633e3f65 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -9,10 +9,18 @@ overrides: query-string: 9.5.1 patchedDependencies: - expo-notifications@55.0.27: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0 - query-string@9.5.1: 8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b - react-native-webview@13.16.2: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 - react-native@0.83.10: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d + expo-notifications@55.0.27: + hash: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0 + path: patches/expo-notifications@55.0.27.patch + query-string@9.5.1: + hash: 8624f1beedd0705bfdaf287519ed6363662a0c96f01fd594508b34583bf30d8b + path: patches/query-string@9.5.1.patch + react-native-webview@13.16.2: + hash: de6761dfa76a5491a23e49f1262566a8831cab26736a336fdffc5d4e7d05ff27 + path: patches/react-native-webview@13.16.2.patch + react-native@0.83.10: + hash: 44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d + path: patches/react-native@0.83.10.patch importers: @@ -7211,14 +7219,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.8 + '@babel/generator': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.9 + '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8 - '@babel/types': 7.29.8 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -7246,7 +7254,7 @@ snapshots: '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.9 + '@babel/parser': 7.29.8 '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 @@ -7373,7 +7381,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -7470,7 +7478,7 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.8 + '@babel/types': 7.29.7 '@babel/highlight@7.25.9': dependencies: @@ -8403,8 +8411,8 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@babel/traverse@7.29.0': dependencies: @@ -8435,7 +8443,7 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.9 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/types': 7.29.8 debug: 4.4.3 @@ -10118,24 +10126,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.8 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.9 - '@babel/types': 7.29.8 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.8 + '@babel/types': 7.29.7 '@types/chai@5.2.3': dependencies: @@ -10352,7 +10360,7 @@ snapshots: dependencies: '@types/yargs-parser': 21.0.3 - '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) @@ -10372,7 +10380,7 @@ snapshots: dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/typescript-estree': 8.59.2(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 @@ -10389,15 +10397,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.59.2(typescript@6.0.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) - '@typescript-eslint/types': 8.59.2 - debug: 4.4.3 - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/scope-manager@8.59.2': dependencies: '@typescript-eslint/types': 8.59.2 @@ -10407,10 +10406,6 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.59.2(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 - '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.59.2 @@ -10440,21 +10435,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.59.2(typescript@6.0.3)': - dependencies: - '@typescript-eslint/project-service': 8.59.2(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@6.0.3) - '@typescript-eslint/types': 8.59.2 - '@typescript-eslint/visitor-keys': 8.59.2 - debug: 4.4.3 - minimatch: 10.2.5 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - '@typescript-eslint/utils@8.59.2(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4) @@ -10762,7 +10742,7 @@ snapshots: babel-plugin-jest-hoist@29.6.3: dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.8 + '@babel/types': 7.29.7 '@types/babel__core': 7.20.5 '@types/babel__traverse': 7.28.0 @@ -11767,11 +11747,11 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@6.0.3) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) eslint-plugin-n: 17.24.0(eslint@9.39.4)(typescript@5.9.3) eslint-plugin-node: 11.1.0(eslint@9.39.4) eslint-plugin-prettier: 5.5.5(eslint-config-prettier@9.1.2(eslint@9.39.4))(eslint@9.39.4)(prettier@2.8.8) @@ -11795,7 +11775,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4): dependencies: debug: 3.2.7 optionalDependencies: @@ -11818,7 +11798,7 @@ snapshots: eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint@9.39.4): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -11829,7 +11809,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@6.0.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint@9.39.4) hasown: 2.0.3 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -12890,7 +12870,7 @@ snapshots: istanbul-lib-instrument@5.2.1: dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.9 + '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 semver: 6.3.1 @@ -15082,10 +15062,6 @@ snapshots: dependencies: typescript: 5.9.3 - ts-api-utils@2.5.0(typescript@6.0.3): - dependencies: - typescript: 6.0.3 - ts-declaration-location@1.0.7(typescript@5.9.3): dependencies: picomatch: 4.0.4 From 0c2ca11a0b69ab3fcc6df0d2471019b8d0c5c97b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:57:14 -0700 Subject: [PATCH 46/66] fix(native-chat): pass a classified journal refusal through a send or Stop unchanged --- .../journal-open-failure.ts | 9 +- ...ured-agent-session-import-mismatch.test.ts | 138 ++++++++++++++++++ .../structured-agent-session-turns.ts | 5 +- 3 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-import-mismatch.test.ts 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 f83c49bd1928..f1fb8c4e8c44 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 @@ -66,14 +66,21 @@ function journalOpenFailureWords(error: unknown): { reason: JournalOpenFailure; return { reason, message: JOURNAL_OPEN_MESSAGE[reason] } } -/** The refusal a failed journal open answers with, as the wire carries it. */ +/** 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) } 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-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index aa1fcc2ad49c..f5fc29bbb75d 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -23,6 +23,7 @@ import { type AgentSessionSendResult, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import { DISPATCH_DOUBT_PERSISTENCE_FAILED } from '../agent-session-journal/journal-dispatch-doubt-reasons' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { latestJournalDispatchObservation } from '../agent-session-journal/journal-dispatch-observation' @@ -146,8 +147,10 @@ export async function performSend( await ctx.journal.appendSubmission({ ...input, fence: ctx.fence, handoverRecorded: true }) } catch (error) { // Damage SQLite proves is the chat's, and no retry writes past it: say so, as an open does. So - // does a chat holding a newer Orca's rows, which only an update writes past. + // does a chat holding a newer Orca's rows, which only an update writes past, and a refusal the + // journal already classified (a copy that did not verify). if ( + isAgentSessionRefusalError(error) || classifyJournalOpenFailure(error) === 'journalCorrupt' || isJournalWrittenByNewerOrca(error) ) { From e130f3fec5fa5c770f641fc5869edeeacefba66f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:57:22 -0700 Subject: [PATCH 47/66] fix(native-chat): refuse a read whose owed copy fails as a failed open does --- ...red-agent-session-conversation-lifetime.ts | 9 ++++-- ...ent-session-restore-without-import.test.ts | 28 ++++++++++++++++++- 2 files changed, 34 insertions(+), 3 deletions(-) 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 ade15037f37b..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) @@ -101,7 +106,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { // 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 open.journal.whenImported() + await whenImported(sessionId, open) } readRefusals.forget(sessionId) return open @@ -132,7 +137,7 @@ export function createStructuredAgentSessionConversationLifetime(host: { } // Restore may have opened it while this waited. if (session.journal.importPending) { - await session.journal.whenImported() + await whenImported(sessionId, session) } readRefusals.forget(sessionId) return session diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 370820f084d1..69c165d97adc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -4,13 +4,14 @@ // every chat had its own file, and opens no file it does not restore. import { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readdir, readFile, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AgentSessionRecord } from '../../../shared/agent-session-record' import type { AgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import { latestStructuredAgentSessionPrompt } from '../../../shared/structured-agent-session-projection' +import { AgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import Database from '../../sqlite/sync-database' import type * as SyncDatabaseModule from '../../sqlite/sync-database' @@ -260,6 +261,7 @@ beforeEach(async () => { }) afterEach(async () => { + vi.restoreAllMocks() closeTestJournalHostDatabases() await rm(root, { recursive: true, force: true }) }) @@ -415,6 +417,30 @@ describe('startup restore of chats still in their per-chat files', () => { expect(since.ok && since.rows.map((row) => row.seq)).toEqual(rows.map((row) => row.seq)) }) + // Whether the read finds the chat restore listed, or opens it and lands on the one restore opened. + it.each(['listed', 'opened'] as const)( + 'refuses a read of the chat restore %s when its copy meets damage, never with the storage text', + async (reach) => { + await seedLegacyChat('chat-a') + const { sessions, lifetime, lifetimeOver } = await restore(['chat-a']) + const restored = sessions.get('chat-a')! + await rm(legacyDirFor('chat-a'), { recursive: true, force: true }) + await mkdir(legacyDirFor('chat-a'), { recursive: true }) + await writeFile(legacyFile('chat-a'), 'not a database, and never was one') + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const reader = + reach === 'listed' ? lifetime : lifetimeOver(conversations(), async () => restored) + + const read = reader.conversation('chat-a') + + await expect(read).rejects.toBeInstanceOf(AgentSessionRefusalError) + await expect(read).rejects.toMatchObject({ + message: 'agent_session_journal_unreadable', + refusal: { details: { reason: 'journalCorrupt' } } + }) + } + ) + it('copies a chat before its first write, and the write lands after its history', async () => { const rows = await seedLegacyChat('chat-a') const { sessions } = await restore(['chat-a']) From 6c1d0b13c5c4fe187727af40b685b4decfb24af4 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:35:23 -0700 Subject: [PATCH 48/66] test(native-chat): measure only the replace's WAL in the block-key case Opening the chats starts a free-page pass that waits one event-loop turn, and the seed never yields one, so that pass was still pending when the replace committed. It woke during the async stat and reclaimed the pages the replace freed, adding ~500 KB of WAL whenever the stat lost the race (Linux CI). Drain that pass before measuring and stub the replace's own. --- .../agent-session-journal/journal-block-replace.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts b/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts index 312eca29b0d3..c00feb258851 100644 --- a/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts @@ -7,7 +7,7 @@ import { mkdtemp, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import { journalDatabasePath } from './journal-host-database' import { @@ -59,7 +59,12 @@ it('replaces one chat among 19 interleaved peers with a write-ahead log a fracti ) } } - const { db } = openTestJournalHostDatabase(root) + const database = openTestJournalHostDatabase(root) + // A free-page pass writes its own WAL frames a turn after a delete; this measures the replace alone. + // The seed never yields a turn, so the pass opening the chats started is still waiting: drain it first. + await database.reclaimFreePages() + vi.spyOn(database, 'reclaimFreePages').mockResolvedValue() + const { db } = database db.pragma('synchronous = FULL') db.pragma('wal_autocheckpoint = 0') db.pragma('wal_checkpoint(TRUNCATE)') From 6bef62f9f6c571b808bbce1a1f61c43a4711f551 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:21:10 -0700 Subject: [PATCH 49/66] test(native-chat): the RPC fixture's status journal can save its listing status The status feed now hands every projection to the journal, which decides whether it is worth saving. --- .../rpc/methods/structured-agent-session-rpc.test-fixture.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 9a0f3902496b..5894a22ff725 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -102,7 +102,8 @@ function statusFeed(): StructuredAgentSessionStatusFeed { isReadOnly: false, cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, - snapshot: () => ({ items: STATUS_ITEMS }) + snapshot: () => ({ items: STATUS_ITEMS }), + saveListingStatus: () => undefined } as unknown as AgentSessionJournal, params: { location: { From 8a05f939fd3d1a29e24a7774fc9848dd85b01452 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 01:28:07 -0700 Subject: [PATCH 50/66] test(native-chat): state why the RPC fixture's status journal cast is safe --- .../rpc/methods/structured-agent-session-rpc.test-fixture.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index 5894a22ff725..bcff13d51bcf 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -98,6 +98,7 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only these five members of its journal. journal: { isReadOnly: false, cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), From 76cdb3be0f55e912df71aa1c67c6335b57fd9fb2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:07:13 -0700 Subject: [PATCH 51/66] fix(native-chat): refuse a per-chat copy whose rows differ from the file, not only its counts --- .../journal-per-session-import.test.ts | 76 ++++++++++++++----- .../journal-per-session-import.ts | 16 ++-- 2 files changed, 66 insertions(+), 26 deletions(-) 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 index aef1c27dacdd..6d46bf86d9ae 100644 --- 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 @@ -14,6 +14,7 @@ import type { 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 { @@ -139,31 +140,42 @@ async function removeWorks(): Promise { vi.mocked(rmSync).mockImplementation(actual.rmSync) } -/** The file as it is, except that the copy's first read of rows loses the first one. */ -function losingFirstCopiedRow(path: string): Database.Database { - const source = new Database(path, { readonly: true, fileMustExist: true }) - const prepare = source.prepare.bind(source) - let lost = false - source.prepare = (sql: string) => { - const statement = prepare(sql) - if (lost || !sql.includes('seq > ?')) { - return statement - } - const all = statement.all.bind(statement) - // Why: prepare caches statements, so this one is handed out again after it has lost its row. - statement.all = (...args: Parameters) => { - const rows = all(...args) - if (lost) { - return rows +/** 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) } - lost = true - return rows.slice(1) + return statement } - return statement + return source } - 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) }) } @@ -402,6 +414,30 @@ describe('importing a per-chat journal', () => { 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(readJournalSessionPointer(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 () => { 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 index e29232d65c7d..b7c0c5d2a096 100644 --- 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 @@ -9,7 +9,7 @@ // // The copy runs in bounded batches, each its own transaction, yielding the event loop between them. // The rows go into a block `journal_import_blocks` reserves, which no reader follows. Once the -// copied block reads back as the file does (epoch, tip, rows, items, submissions), one transaction +// copied block reads 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 that reserved block, which the next try // clears and copies again. A copy that does not read back as the file is never published: the @@ -19,6 +19,7 @@ // 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' @@ -277,9 +278,9 @@ const loggedMismatches = new Set() /** * The copied block, read back from the host's database, against a second read of what was copied: - * 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. + * 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, @@ -302,9 +303,10 @@ async function verifyCopiedJournal( throw journalOpenRefusalError(error) } -/** Epoch, tip, row count, items and submissions, folded a batch at a time. */ +/** 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 @@ -317,6 +319,8 @@ async function copyFacts(sessionId: string, batches: Iterable): Pro 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 @@ -324,7 +328,7 @@ async function copyFacts(sessionId: string, batches: Iterable): Pro } } } - return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}` + return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}:${content.digest('hex')}` } function* copiedBatches( From 08ea4f678e3d85bc7b767aa7fc4fd224e19c225c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:10:44 -0700 Subject: [PATCH 52/66] fix(bench): build the replay benchmark's baseline arm from the base tree and release its handles on failure --- .../journal-replay-retention-benchmark.mjs | 163 +++++++++++------- 1 file changed, 99 insertions(+), 64 deletions(-) diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs index 7240a340eab5..519345767461 100644 --- a/config/scripts/journal-replay-retention-benchmark.mjs +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -2,77 +2,89 @@ 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'; export {tryAcquireJournalOwnerLock} from '${journalSource}/journal-owner-lock';` +} +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 {replayJournal} from './src/main/native-chat/agent-session-journal/journal-open'; export {JournalHostDatabase, journalDatabasePath} from './src/main/native-chat/agent-session-journal/journal-host-database'; export {tryAcquireJournalOwnerLock} from './src/main/native-chat/agent-session-journal/journal-owner-lock';", - 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 { + const lock = implementation.tryAcquireJournalOwnerLock(stateDirectory) + assert.ok(lock, 'owner lock refused') + releases.push(() => lock.release()) + database = implementation.JournalHostDatabase.open(lock) + releases.push(() => database.close()) + journal = await implementation.openAgentSessionJournal({ identity, database }) } - const stateDirectory = join(fixture, 'state') - const lock = implementations.current.tryAcquireJournalOwnerLock(stateDirectory) - const database = implementations.current.JournalHostDatabase.open(lock) - const journal = await implementations.current.openAgentSessionJournal({ identity, database }) + 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++) { @@ -86,11 +98,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].replayJournal(database.db, 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) @@ -105,7 +135,7 @@ try { global.gc() peakLiveHeap = Math.max(peakLiveHeap, process.memoryUsage().heapUsed) } - loaded = implementations[arm].replayJournal(database.db, identity.sessionId) + loaded = arms[arm].replay() delete globalThis.__replayMemoryProbe assert.equal(loaded.state.items.size, 1) loaded = null @@ -113,14 +143,19 @@ try { JSON.stringify({ arm, ms, - databaseBytes: (await stat(implementations[arm].journalDatabasePath(stateDirectory))).size, + databaseBytes: (await stat(arms[arm].path)).size, peakLiveHeapDelta: peakLiveHeap - initialHeap }) ) } - database.close() - lock.release() } 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 }) } From e2e600159318796280e5ee51565cdac236bdb1d7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:15:52 -0700 Subject: [PATCH 53/66] fix(native-chat): retry a failed listing status save on the next read of a cached status --- .../journal-session-status.ts | 10 +++ ...session-status-feed-listing-status.test.ts | 81 +++++++++++++++++++ .../structured-agent-session-status-feed.ts | 3 +- 3 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-listing-status.test.ts diff --git a/src/main/native-chat/agent-session-journal/journal-session-status.ts b/src/main/native-chat/agent-session-journal/journal-session-status.ts index dbae152285bc..276430ac2a15 100644 --- a/src/main/native-chat/agent-session-journal/journal-session-status.ts +++ b/src/main/native-chat/agent-session-journal/journal-session-status.ts @@ -32,6 +32,9 @@ export function writeJournalSessionStatus( export class JournalListingStatusWriter { /** The epoch and projection last saved or in flight. */ private saved: string | null = null + /** The projection object behind `saved`: the status feed re-offers its cached one on every read. */ + private offered: { epoch: string; projection: StructuredAgentSessionStatusProjection } | null = + null constructor( private readonly deps: { @@ -46,8 +49,13 @@ export class JournalListingStatusWriter { projection: StructuredAgentSessionStatusProjection, lastActivityAt: number ): void { + if (this.offered?.projection === projection && this.offered.epoch === cursor.epoch) { + return + } const key = `${cursor.epoch}\n${JSON.stringify(projection)}` + const offered = { epoch: cursor.epoch, projection } if (key === this.saved) { + this.offered = offered return } const status: StructuredAgentSessionSavedStatus = { @@ -56,6 +64,7 @@ export class JournalListingStatusWriter { lastActivityAt } this.saved = key + this.offered = offered this.deps .serialize(async () => writeJournalSessionStatus( @@ -68,6 +77,7 @@ export class JournalListingStatusWriter { .catch((error: unknown) => { if (this.saved === key) { this.saved = null + this.offered = null } if (!(error instanceof AgentSessionJournalError && error.code === 'journal_closed')) { console.warn('[agent-session-journal] saving the listing status failed', error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-listing-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-listing-status.test.ts new file mode 100644 index 000000000000..e0f671dee07d --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-listing-status.test.ts @@ -0,0 +1,81 @@ +// The status feed saves each chat's settled status for the next boot's listing. A save is +// bookkeeping and may fail; the next read of the same status retries it instead of stranding it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase +} from '../agent-session-journal/journal-host-database-test-support' +import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' +import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' + +const SESSION = 'listing-status-session' + +let root: string +const journals = createTrackedJournalOpener() + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-status-feed-listing-')) +}) + +afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) +}) + +async function settled(): Promise { + // The save joins the chat's queue behind the rows it describes. + await new Promise((resolve) => setImmediate(resolve)) +} + +it('retries a failed listing status save on the next read of a cached projection', async () => { + const journal = await journals.open({ + identity: { + sessionId: SESSION, + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + }, + stateDirectory: join(root, SESSION) + }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, + { fence: 1 } + ) + const { db } = openTestJournalHostDatabase(join(root, SESSION)) + const prepare = db.prepare.bind(db) + let failures = 1 + vi.spyOn(db, 'prepare').mockImplementation((sql: string) => { + if (sql.startsWith('UPDATE journal_sessions SET status_json') && failures-- > 0) { + throw new Error('disk I/O error') + } + return prepare(sql) + }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const savedAt = () => + db.prepare('SELECT status_seq FROM journal_sessions WHERE session_id = ?').get(SESSION) + ?.status_seq ?? null + const feed = new StructuredAgentSessionStatusFeed({ + sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), + getRecord: () => null, + now: () => 1_000 + }) + feed.subscribe({ id: 'list-1', emit: () => undefined }) + await settled() + expect(warn).toHaveBeenCalledOnce() + expect(savedAt()).toBeNull() + const snapshot = vi.spyOn(journal, 'snapshot') + + feed.publish(SESSION) + await settled() + + // Retried from the cached projection: the journal is not read again. + expect(snapshot).not.toHaveBeenCalled() + expect(savedAt()).toBe(journal.cursor().sequence) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index 968f31579782..b6c5aebf2a4a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -341,8 +341,9 @@ export class StructuredAgentSessionStatusFeed { ) } this.journalProjections.set(journal, projection) - journal.saveListingStatus(projection.state.summary) } + // Also on a cached projection, so a failed save is retried; an unchanged one is skipped. + journal.saveListingStatus(projection.state.summary) return projection.state } From 3cbf1418b0e154091a7c66f6ea201a03821ef91f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:30:41 -0700 Subject: [PATCH 54/66] refactor(native-chat): drop the chat journal owner lock; the process instance lock already guards the profile The journal carried its own exclusive lock, with a retry loop, an in-process takeover, lock-gated runtime discovery and a "chats are open in another Orca" refusal. Every shipped process kind (packaged desktop, serve mode, orcad) already refuses a second instance on one profile before the journal opens, so the lock only ever mattered for dev desktops, which the next commit covers at the process level instead. The host now opens its one journal connection at install with no lock. What a sole process whose journal will not open needs stays: the install refusal recorded for the gate, the no-host startup path, and the unverifiable chat inventory, now in structured-agent-session-host-refusal.ts. The unreleased journalOwnedElsewhere reason, its processKind fact and their copy are removed. --- .../journal-replay-retention-benchmark.mjs | 7 +- ...tured-agent-session-thrown-refusal.test.ts | 16 +- .../ai-vault/structured-session-ownership.ts | 2 +- .../register-core-handlers.test.ts | 2 +- .../register-core-handlers.ts | 2 +- .../journal-host-database-test-support.ts | 29 +- .../journal-host-database.ts | 14 +- .../journal-open-failure.ts | 5 +- .../journal-owner-lock-test-support.ts | 81 -- .../journal-owner-lock.test.ts | 139 ---- .../journal-owner-lock.ts | 122 --- .../journal-per-session-import.test.ts | 2 +- .../structured-agent-session-host-types.ts | 2 +- src/main/orcad/orcad-entry.ts | 4 - ...structured-agent-session-create-support.ts | 47 +- src/main/runtime/orca-runtime-runtime-id.ts | 2 - src/main/runtime/rpc/errors.test.ts | 10 +- src/main/runtime/rpc/methods/ai-vault.test.ts | 2 +- src/main/runtime/rpc/methods/ai-vault.ts | 2 +- .../methods/structured-agent-session-gate.ts | 7 +- .../runtime-rpc/runtime-rpc-lifecycle.ts | 46 +- .../runtime-rpc/runtime-rpc-pairing-types.ts | 6 - .../runtime-rpc/runtime-rpc-shutdown.ts | 2 - .../runtime/runtime-rpc/runtime-rpc-state.ts | 4 - ...uctured-agent-session-host-refusal.test.ts | 374 +++++++++ .../structured-agent-session-host-refusal.ts | 47 ++ ...ructured-agent-session-integration.test.ts | 2 +- .../structured-agent-session-journal-open.ts | 32 +- ...ed-agent-session-journal-ownership.test.ts | 758 ------------------ ...uctured-agent-session-journal-ownership.ts | 196 ----- .../structured-agent-session-runtime.ts | 53 +- .../startup/main-process-runtime-launch.ts | 5 +- ...eChatStructuredSession.read-error.test.tsx | 18 - .../agent-session-write-notice-text.ts | 14 - ...tured-agent-session-read-transport.test.ts | 14 +- ...nt-session-outbox-rejection-cause.test.tsx | 6 +- .../src/i18n/en-runtime-required.json | 4 - src/renderer/src/i18n/locales/en.json | 4 - src/renderer/src/i18n/locales/es.json | 4 - src/renderer/src/i18n/locales/fr.json | 4 - src/renderer/src/i18n/locales/ja.json | 4 - src/renderer/src/i18n/locales/ko.json | 4 - src/renderer/src/i18n/locales/zh.json | 4 - src/shared/agent-session-refusal-details.ts | 38 +- .../agent-session-refusal-notice.test.ts | 29 +- src/shared/agent-session-refusal-notice.ts | 12 +- .../agent-session-write-failure.test.ts | 14 +- src/shared/agent-session-write-failure.ts | 6 +- src/shared/agent-session-write-notice-copy.ts | 17 - 49 files changed, 528 insertions(+), 1690 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-owner-lock-test-support.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-owner-lock.ts create mode 100644 src/main/runtime/structured-agent-session-host-refusal.test.ts create mode 100644 src/main/runtime/structured-agent-session-host-refusal.ts delete mode 100644 src/main/runtime/structured-agent-session-journal-ownership.test.ts delete mode 100644 src/main/runtime/structured-agent-session-journal-ownership.ts diff --git a/config/scripts/journal-replay-retention-benchmark.mjs b/config/scripts/journal-replay-retention-benchmark.mjs index 519345767461..ed3476702777 100644 --- a/config/scripts/journal-replay-retention-benchmark.mjs +++ b/config/scripts/journal-replay-retention-benchmark.mjs @@ -19,7 +19,7 @@ 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'; export {tryAcquireJournalOwnerLock} from '${journalSource}/journal-owner-lock';` + 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 = { @@ -77,10 +77,7 @@ async function openArm(arm) { if (arm === 'baseline') { journal = await implementation.openAgentSessionJournal({ identity, journalDir: stateDirectory }) } else { - const lock = implementation.tryAcquireJournalOwnerLock(stateDirectory) - assert.ok(lock, 'owner lock refused') - releases.push(() => lock.release()) - database = implementation.JournalHostDatabase.open(lock) + database = implementation.JournalHostDatabase.open(stateDirectory) releases.push(() => database.close()) journal = await implementation.openAgentSessionJournal({ identity, database }) } 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 index 5391cdd322ca..b21a249da608 100644 --- a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts @@ -8,33 +8,32 @@ import { // 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_OWNER_REFUSAL = { +const THROWN_JOURNAL_REFUSAL = { code: 'runtime_error', message: 'agent_session_journal_unreadable', data: { refusal: { code: 'agent_session_journal_unreadable', - details: { reason: 'journalOwnedElsewhere', processKind: 'packaged' } + details: { reason: 'journalCorrupt' } } } } function refusingClient(): RpcClient { - const sendRequest = async () => ({ id: 'req-1', ok: false, error: THROWN_OWNER_REFUSAL }) + 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 = - "Chats are open in another Orca using this profile. This chat's history couldn't be loaded. Quit that Orca to use chats here." + 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_OWNER_REFUSAL.message, - error: THROWN_OWNER_REFUSAL + message: THROWN_JOURNAL_REFUSAL.message, + error: THROWN_JOURNAL_REFUSAL }) ).toBe(words) const thrown = await callAgentSession(refusingClient(), 'agentSession.history', {}).catch( @@ -59,8 +58,7 @@ describe('a refusal the host threw', () => { expect(result).toEqual({ status: 'failed', - message: - "Chats are open in another Orca using this profile. The agent wasn't stopped. Quit that Orca to use chats here." + message: "Unable to load this chat. The agent wasn't stopped." }) }) }) diff --git a/src/main/ai-vault/structured-session-ownership.ts b/src/main/ai-vault/structured-session-ownership.ts index f8e21f191f77..67624f612bc4 100644 --- a/src/main/ai-vault/structured-session-ownership.ts +++ b/src/main/ai-vault/structured-session-ownership.ts @@ -2,7 +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-journal-ownership' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../runtime/structured-agent-session-host-refusal' import { listStructuredProviderSessionOwnership, type StructuredProviderSessionOwnership 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 e855c3690cf2..718267e94810 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 @@ -401,7 +401,7 @@ vi.mock('../native-chat', () => ({ })) import { agentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { recordStructuredAgentSessionHostInstallRefusal } from '../../runtime/structured-agent-session-journal-ownership' +import { recordStructuredAgentSessionHostInstallRefusal } from '../../runtime/structured-agent-session-host-refusal' import { registerCoreHandlers } from './register-core-handlers' let registeredAiVaultOptions: { 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 f957ce37680a..c85058c34d70 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -93,7 +93,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-journal-ownership' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../../runtime/structured-agent-session-host-refusal' import type { PluginService } from '../../plugins/plugin-service' import type { PluginMarketplaceHandlerServices } from '../plugin-marketplaces' diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index c1394758d147..0b89b2cfe7d0 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -1,7 +1,6 @@ -// The host journal database for a test's state directory, opened the one way production opens it: -// under a held owner lock. 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. +// 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' @@ -18,33 +17,27 @@ import { type JournalStoredRow } from './journal-row-table' import type Database from '../../sqlite/sync-database' -import { tryAcquireJournalOwnerLock, type JournalOwnerLock } from './journal-owner-lock' import type { AgentSessionJournal } from './journal-store' import type { AgentSessionJournalOptions } from './journal-store-contracts' import { openAgentSessionJournal } from './journal-store-factory' -const opened = new Map() +const opened = new Map() export function openTestJournalHostDatabase(stateDirectory: string): JournalHostDatabase { const directory = resolve(stateDirectory) const existing = opened.get(directory) - if (existing && !existing.database.isClosed) { - return existing.database + if (existing && !existing.isClosed) { + return existing } - const lock = existing?.lock.isHeld ? existing.lock : tryAcquireJournalOwnerLock(directory) - if (!lock) { - throw new Error(`another process owns the chat journal in ${directory}`) - } - const database = JournalHostDatabase.open(lock) - opened.set(directory, { lock, database }) + const database = JournalHostDatabase.open(directory) + opened.set(directory, database) return database } -/** Closes every database this process opened for tests, then releases their locks. */ +/** Closes every database this process opened for tests. */ export function closeTestJournalHostDatabases(): void { - for (const { lock, database } of opened.values()) { + for (const database of opened.values()) { database.close() - lock.release() } opened.clear() } @@ -57,7 +50,7 @@ export type TestJournalOptions = Omit & export type TrackedJournalOpener = { open: (options: TestJournalOptions) => Promise track: (journal: T) => T - /** Drains every tracked journal, then closes the databases and releases their locks. */ + /** Drains every tracked journal, then closes the databases. */ closeAll: () => Promise } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index 52231d0875bb..094ec33ab1fa 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -1,9 +1,10 @@ // The host's one chat journal database: every structured chat on this state directory, in one -// file, on one connection, opened only by the process holding the owner lock. +// 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 { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -11,7 +12,6 @@ import type Database from '../../sqlite/sync-database' import { freePageCount, reclaimFreePagesStep } from '../../sqlite/sqlite-free-page-reclaim' import { JOURNAL_SYNCHRONOUS, openJournalDatabase, runJournalTransaction } from './journal-database' import { journalOpenRefusalError } from './journal-open-failure' -import type { JournalOwnerLock } from './journal-owner-lock' import { journalDirectoryFor } from './journal-paths' import { AgentSessionJournalError } from './journal-write-guards' @@ -34,12 +34,8 @@ export class JournalHostDatabase { this.connection = connection } - /** Takes the lock as proof of ownership: no other path opens this file. */ - static open(lock: JournalOwnerLock): JournalHostDatabase { - if (!lock.isHeld) { - throw new Error('the chat journal opens only under a held owner lock') - } - const stateDirectory = lock.stateDirectory + static open(stateDirectory: string): JournalHostDatabase { + mkdirSync(stateDirectory, { recursive: true }) return new JournalHostDatabase( stateDirectory, openJournalDatabase(journalDatabasePath(stateDirectory)) @@ -104,7 +100,7 @@ export class JournalHostDatabase { } /** Last, after every store has drained. A close that fails keeps the handle, so the retried - * teardown closes this same connection before the owner lock goes. */ + * teardown closes this same connection. */ close(): void { this.connection?.close() this.connection = null 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 f1fb8c4e8c44..ce9f1f0c2921 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 @@ -14,10 +14,7 @@ import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' import { JournalDatabaseNewerSchemaError } from './journal-database' import { AgentSessionJournalError } from './journal-write-guards' -export type JournalOpenFailure = Exclude< - AgentSessionRefusalReason<'agent_session_journal_unreadable'>, - 'journalOwnedElsewhere' -> +export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> /** A per-chat file whose copy did not read back as the file: the history is not usable here. */ export class JournalImportMismatchError extends Error { diff --git a/src/main/native-chat/agent-session-journal/journal-owner-lock-test-support.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock-test-support.ts deleted file mode 100644 index dba479760306..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-owner-lock-test-support.ts +++ /dev/null @@ -1,81 +0,0 @@ -// Another process on the same state directory, for tests of the owner lock. The lock is a kernel -// lock held per process, so only a real second process can stand in for a second Orca. - -import { join } from 'node:path' -import { runProcess, spawnProcess } from '../../../shared/child-process/run-process' -import { JOURNAL_OWNER_LOCK_FILE } from './journal-owner-lock' - -const HOLD_SCRIPT = ` -const { DatabaseSync } = require('node:sqlite') -const db = new DatabaseSync(process.argv[1], { timeout: 0 }) -db.exec('BEGIN EXCLUSIVE') -process.stdout.write('held\\n') -setInterval(() => {}, 1000) -` - -const PROBE_SCRIPT = ` -const { DatabaseSync } = require('node:sqlite') -const db = new DatabaseSync(process.argv[1], { timeout: 0 }) -try { - db.exec('BEGIN EXCLUSIVE') - process.stdout.write('acquired') -} catch { - process.stdout.write('refused') -} -` - -export type JournalOwnerLockHolder = { - pid: number - /** SIGKILL, the way a crashed Orca dies; resolves once the process is gone. */ - kill: () => Promise -} - -/** A second process holding the state directory's owner lock until it is killed. */ -export async function holdJournalOwnerLockInChild( - stateDirectory: string -): Promise { - const child = spawnProcess({ - program: process.execPath, - args: ['-e', HOLD_SCRIPT, join(stateDirectory, JOURNAL_OWNER_LOCK_FILE)], - // Bounded: a test that never kills it still does not leave it running. - timeoutMs: 60_000 - }) - const pid = child.pid - if (pid === undefined) { - throw new Error('the lock holder did not start') - } - const exited = new Promise((resolve) => child.once('exit', () => resolve())) - await new Promise((resolve, reject) => { - let output = '' - child.stdout.on('data', (chunk: Buffer) => { - output += chunk.toString('utf8') - if (output.includes('held')) { - resolve() - } - }) - child.once('exit', (code) => reject(new Error(`the lock holder exited early (${code})`))) - }) - return { - pid, - kill: async () => { - // Only the process this helper started, by its own handle. - child.kill('SIGKILL') - await exited - } - } -} - -/** Whether a separate process could take the owner lock right now. It releases it at once. */ -export async function probeJournalOwnerLockInChild( - stateDirectory: string -): Promise<'acquired' | 'refused'> { - const result = await runProcess({ - program: process.execPath, - args: ['-e', PROBE_SCRIPT, join(stateDirectory, JOURNAL_OWNER_LOCK_FILE)], - timeoutMs: 30_000 - }) - if (result.stdout === 'acquired' || result.stdout === 'refused') { - return result.stdout - } - throw new Error(`the lock probe failed: ${result.stderr}`) -} diff --git a/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts deleted file mode 100644 index 97c202b4ed1e..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-owner-lock.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -// The owner lock: one process per state directory opens the chat journal. - -import { existsSync } from 'node:fs' -import { mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { sweepOrphanedRuntimeSockets } from '../../runtime/runtime-rpc/runtime-rpc-socket-metadata' -import { copyProfileStateRecoveryFile } from '../../persistence/profile-state/profile-state-recovery-copy' -import { JournalHostDatabase, journalDatabasePath } from './journal-host-database' -import { - JOURNAL_OWNER_LOCK_FILE, - retryJournalOwnerLock, - tryAcquireJournalOwnerLock, - type JournalOwnerLock -} from './journal-owner-lock' -import { - holdJournalOwnerLockInChild, - probeJournalOwnerLockInChild, - type JournalOwnerLockHolder -} from './journal-owner-lock-test-support' - -let root: string -let holder: JournalOwnerLockHolder | null = null -const locks: JournalOwnerLock[] = [] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-owner-')) -}) - -afterEach(async () => { - await holder?.kill() - holder = null - for (const lock of locks.splice(0)) { - lock.release() - } - await rm(root, { recursive: true, force: true }) -}) - -function acquire(): JournalOwnerLock | null { - const lock = tryAcquireJournalOwnerLock(root) - if (lock) { - locks.push(lock) - } - return lock -} - -describe('the owner lock', () => { - // T-B1: a second process is refused, never opens the database, and takes over once the owner - // dies — with no stale lock to clean up. - it('refuses a second process until the owner dies, then grants it at once', async () => { - holder = await holdJournalOwnerLockInChild(root) - - expect(acquire()).toBeNull() - expect(existsSync(journalDatabasePath(root))).toBe(false) - - await holder.kill() - holder = null - const lock = acquire() - expect(lock?.isHeld).toBe(true) - // The owner can now open the one database; nobody could before. - JournalHostDatabase.open(lock!).close() - }) - - it('keeps the lock file empty and never grows it a write-ahead log', async () => { - const lock = acquire() - expect(lock).not.toBeNull() - const path = join(root, JOURNAL_OWNER_LOCK_FILE) - expect((await stat(path)).size).toBe(0) - expect(await readdir(root)).not.toContain(`${JOURNAL_OWNER_LOCK_FILE}-wal`) - expect(await probeJournalOwnerLockInChild(root)).toBe('refused') - }) - - it('opens the database only under a lock it still holds', () => { - const lock = acquire()! - lock.release() - expect(() => JournalHostDatabase.open(lock)).toThrow('owner lock') - expect(existsSync(journalDatabasePath(root))).toBe(false) - }) - - // T-owner-copy (N-R1): a POSIX close of ANY descriptor on the lock's inode drops the whole - // process's lock. These two run in the owner's state directory; neither may drop it. - it('stays held through the runtime socket sweep and the profile-state recovery copy', async () => { - expect(acquire()).not.toBeNull() - await writeFile(join(root, 'o-999999-stale.sock'), '') - await writeFile(join(root, 'orca-data.json'), '{}') - - sweepOrphanedRuntimeSockets(root, process.pid) - copyProfileStateRecoveryFile(join(root, 'orca-data.json'), join(root, 'orca-data.json.copy')) - - expect(await probeJournalOwnerLockInChild(root)).toBe('refused') - }) -}) - -describe('retrying a refused lock', () => { - it('backs off from one second to five and stops once it is granted', () => { - const delays: number[] = [] - const pending: (() => void)[] = [] - let grantOn = 7 - const granted: JournalOwnerLock[] = [] - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the retry only hands the value it was given to `onAcquired`. - const fakeLock = { isHeld: true } as unknown as JournalOwnerLock - retryJournalOwnerLock({ - stateDirectory: root, - acquire: () => (--grantOn === 0 ? fakeLock : null), - onAcquired: (lock) => granted.push(lock), - schedule: (run, delayMs) => { - delays.push(delayMs) - pending.push(run) - return { cancel: () => undefined } - } - }) - while (pending.length > 0) { - pending.shift()!() - } - expect(delays).toEqual([1_000, 2_000, 4_000, 5_000, 5_000, 5_000, 5_000]) - expect(granted).toEqual([fakeLock]) - }) - - it('dies when cancelled', () => { - const pending: (() => void)[] = [] - let attempts = 0 - const retry = retryJournalOwnerLock({ - stateDirectory: root, - acquire: () => { - attempts += 1 - return null - }, - onAcquired: () => undefined, - schedule: (run) => { - pending.push(run) - return { cancel: () => pending.splice(0) } - } - }) - retry.cancel() - expect(pending).toEqual([]) - expect(attempts).toBe(0) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-owner-lock.ts b/src/main/native-chat/agent-session-journal/journal-owner-lock.ts deleted file mode 100644 index 24f2bdb3f975..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-owner-lock.ts +++ /dev/null @@ -1,122 +0,0 @@ -// The one process allowed to open this state directory's chat journal. -// -// Two Orca processes on one profile corrupt each other's chats: accepting a send and opening a -// chat both write without a lease, and each process mints sequence numbers from its own fold. -// Dev desktops skip the single-instance lock and share one profile, and a packaged build's lock -// lives in a `$TMPDIR` socket macOS purges (#7848). So the journal carries its own lock. -// -// The lock is a held `BEGIN EXCLUSIVE` on an empty SQLite file: a kernel byte-range lock that is -// refused while another process holds it and released when the holder dies, so there is no stale -// lock to clean up and no timeout to guess. The holder never writes, so the file stays empty. -// -// INVARIANT: in the owning process nothing but this connection may open or close a descriptor on -// the `.owner` file. A POSIX close of ANY descriptor on that inode drops every lock the process -// holds on it, so a copier that reads the file would silently hand ownership away. - -import { mkdirSync } from 'node:fs' -import { join } from 'node:path' -import Database from '../../sqlite/sync-database' -import { isTransientSqliteContention } from '../../sqlite/sqlite-read-failure' - -export const JOURNAL_OWNER_LOCK_FILE = 'agent-session-journal.owner' - -export class JournalOwnerLock { - private held: Database | null - - constructor( - readonly stateDirectory: string, - connection: Database - ) { - this.held = connection - } - - get isHeld(): boolean { - return this.held !== null - } - - /** Last, after the journal database is closed. Idempotent. */ - release(): void { - const held = this.held - this.held = null - if (!held) { - return - } - try { - held.exec('ROLLBACK') - } finally { - held.close() - } - } -} - -const RETRY_FIRST_DELAY_MS = 1_000 -const RETRY_MAX_DELAY_MS = 5_000 - -/** - * Keeps asking for the lock a peer holds, backing off from 1 s to 5 s. The lock frees as the - * holder exits, but nothing tells this process, so the cap is how long chats stay refused here - * after the owner quits. In memory only: it dies with the process or when cancelled. - */ -export function retryJournalOwnerLock(input: { - stateDirectory: string - onAcquired: (lock: JournalOwnerLock) => void - onError?: (error: unknown) => void - acquire?: (stateDirectory: string) => JournalOwnerLock | null - schedule?: (run: () => void, delayMs: number) => { cancel: () => void } -}): { cancel: () => void } { - const acquire = input.acquire ?? tryAcquireJournalOwnerLock - const schedule = input.schedule ?? scheduleUnrefTimer - let delayMs = RETRY_FIRST_DELAY_MS - let cancelled = false - let pending: { cancel: () => void } | null = null - const attempt = (): void => { - pending = null - if (cancelled) { - return - } - let lock: JournalOwnerLock | null = null - try { - lock = acquire(input.stateDirectory) - } catch (error) { - input.onError?.(error) - } - if (lock) { - input.onAcquired(lock) - return - } - delayMs = Math.min(delayMs * 2, RETRY_MAX_DELAY_MS) - pending = schedule(attempt, delayMs) - } - pending = schedule(attempt, delayMs) - return { - cancel: () => { - cancelled = true - pending?.cancel() - pending = null - } - } -} - -function scheduleUnrefTimer(run: () => void, delayMs: number): { cancel: () => void } { - const timer = setTimeout(run, delayMs) - timer.unref?.() - return { cancel: () => clearTimeout(timer) } -} - -/** The lock, or null while another process holds it. Any other failure throws. */ -export function tryAcquireJournalOwnerLock(stateDirectory: string): JournalOwnerLock | null { - mkdirSync(stateDirectory, { recursive: true }) - // Why no pragma: the holder runs one statement, so it never sets `journal_mode` and can never - // grow a `-wal` beside an empty file. `timeout: 0` makes a held lock refuse at once. - const connection = new Database(join(stateDirectory, JOURNAL_OWNER_LOCK_FILE), { timeout: 0 }) - try { - connection.exec('BEGIN EXCLUSIVE') - return new JournalOwnerLock(stateDirectory, connection) - } catch (error) { - connection.close() - if (isTransientSqliteContention(error)) { - return null - } - throw error - } -} 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 index 6d46bf86d9ae..b5e0f5db2540 100644 --- 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 @@ -453,7 +453,7 @@ describe('importing a per-chat journal', () => { { fence: 1 } ) expect(existsSync(legacyJournalDatabaseFile(legacyDir()))).toBe(true) - // The process exits: the database closes and the owner lock goes with it. + // The process exits and the database closes. await journals.closeAll() await removeWorks() 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 dadf5d30da11..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 @@ -96,7 +96,7 @@ export type StructuredAgentSessionHostDeps = { adapter: StructuredAgentSessionAdapter /** Optional advisory recovery storage, independent of conversation backups. */ recoveryCapsule?: AgentSessionRecoveryCapsule - /** The host's one chat journal database, opened under the owner lock. */ + /** The host's one chat journal database. */ journalDatabase: JournalHostDatabase claimKeyId: string probeOwner?: (record: AgentSessionRecord) => Promise diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 92ae74b567a8..02533588e9dd 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -119,8 +119,6 @@ async function startOrcadRuntime( ): Promise> { const { OrcaRuntimeService } = await import('../runtime/orca-runtime') const { OrcaRuntimeRpcServer } = await import('../runtime/runtime-rpc') - const { setJournalOwnerProcessKind } = - await import('../runtime/structured-agent-session-journal-ownership') const { registerHeadlessPtyRuntime, getLocalPtyProvider, getSshPtyProvider } = await import('../ipc/pty') const { getAppEnvironment } = await import('../../shared/app-environment') @@ -292,11 +290,9 @@ async function startOrcadRuntime( observedStatusCapture.attach(runtime) const bindHost = resolveOrcadBindHost(options.bind) - setJournalOwnerProcessKind('orcad') rpc = new OrcaRuntimeRpcServer({ runtime, userDataPath: runtimeUserDataPath, - journalStateDirectory: runtimeUserDataPath, enableWebSocket: true, // Why pinned and not `exposeNetworkByDefault`: an unattended host's exposure must be // exactly what the operator asked for, on every launch. The default path widens itself diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 2134d48016f4..7f6c57cdf257 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -19,11 +19,7 @@ import { } from './structured-agent-account-home' import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults' import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime' -import { - ensureStructuredAgentSessionHostUnlessRefused, - isStructuredAgentSessionJournalClaimRefused, - onStructuredAgentSessionJournalOwned -} from './structured-agent-session-journal-ownership' +import { ensureStructuredAgentSessionHostUnlessRefused } from './structured-agent-session-host-refusal' import { getProfileUserDataPath } from '../orca-profiles/profile-storage-paths' import { parseWslUncPath } from '../../shared/wsl-paths' import { parseWorkspaceKey } from '../../shared/workspace-scope' @@ -244,8 +240,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR this.structuredAgentSessionTabRestorePromise ??= this.restoreStructuredAgentSessionTabsOnce().then( () => { - // Only a host's answer is final: without one, the next caller restores again, so a later - // lock takeover or journal open republishes the chats. + // Only a host's answer is final: without one, the next caller restores again, so a journal + // that opens later republishes the chats. if (this.structuredAgentSessionInventoryUnverifiable) { this.structuredAgentSessionTabRestorePromise = null } @@ -276,43 +272,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR await ensureStructuredAgentSessionHostUnlessRefused(() => this.ensureStructuredAgentSessionHost() ) - // Read now: a host a chat request installs later is reconciled by the takeover's own run. - const host = getStructuredAgentSessionHost() - if (!host && isStructuredAgentSessionJournalClaimRefused()) { - this.restoreAgainOnceJournalOwned() - } await this.refreshMobileSessionPtyRecords() - await host?.reconcileRestartLeases() - } - - /** A takeover makes this process the owner: startup restoration runs once more, now with a - * host, and a client told "cannot tell" gets the chats pushed, since nothing lists on its own. */ - protected restoreAgainOnceJournalOwned(): void { - this.stopAwaitingStructuredAgentSessionJournal?.() - this.stopAwaitingStructuredAgentSessionJournal = onStructuredAgentSessionJournalOwned(() => { - this.stopAwaitingStructuredAgentSessionJournal?.() - this.stopAwaitingStructuredAgentSessionJournal = null - this.restoreStructuredAgentSessionsAfterTakeover().catch((error) => { - console.error( - '[structured-agent-session] restoring chats after taking ownership failed', - error - ) - }) - }) - } - - protected async restoreStructuredAgentSessionsAfterTakeover(): Promise { - const refusedStartup = this.structuredAgentSessionStartupRestorePromise - await refusedStartup?.catch(() => undefined) - if (this.structuredAgentSessionStartupRestorePromise === refusedStartup) { - this.structuredAgentSessionStartupRestorePromise = null - } - await this.prepareStructuredAgentSessionStartupRestoration() - // Install first, then ask: a list that ran on the refused startup may only now have finished. - await this.structuredAgentSessionTabRestorePromise?.catch(() => undefined) - if (this.structuredAgentSessionInventoryUnverifiable) { - await this.restoreStructuredAgentSessionTabs() - } + await getStructuredAgentSessionHost()?.reconcileRestartLeases() } protected hasPersistedStructuredAgentSessionStore(): boolean { diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index dfe1d30e9718..f033cb456ebc 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -126,8 +126,6 @@ export class OrcaRuntimeWithRuntimeId { protected structuredAgentSessionStartupRestorePromise: Promise | null = null - protected stopAwaitingStructuredAgentSessionJournal: (() => void) | null = null - protected mobileSessionTabsChangeSequence = 0 protected sessionTabsInventoryPublicationEpoch: number | null = null diff --git a/src/main/runtime/rpc/errors.test.ts b/src/main/runtime/rpc/errors.test.ts index 55a400d470ab..fa9106dc2875 100644 --- a/src/main/runtime/rpc/errors.test.ts +++ b/src/main/runtime/rpc/errors.test.ts @@ -358,14 +358,14 @@ describe('thrown agent-session refusals', () => { }) // The shape every client test of a thrown refusal feeds in, pinned to what this produces. - it("sends the owner refusal's reason and process kind where a client reads them", () => { + it("sends a journal refusal's reason where a client reads it", () => { const response = mapRuntimeError( 'req_1', meta, agentSessionRefusalError( 'agent_session_journal_unreadable', - { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' }, - 'Chats are open in another Orca window using this profile.' + { reason: 'journalCorrupt' }, + 'Unable to load this chat.' ) ) const wire = JSON.parse(JSON.stringify(response.error)) @@ -375,13 +375,13 @@ describe('thrown agent-session refusals', () => { data: { refusal: { code: 'agent_session_journal_unreadable', - details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } + details: { reason: 'journalCorrupt' } } } }) expect(readAgentSessionErrorRefusal(wire)).toEqual({ code: 'agent_session_journal_unreadable', - details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } + details: { reason: 'journalCorrupt' } }) }) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index ab22ca4c90d1..a240c0c39191 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -26,7 +26,7 @@ import { AiVaultPrepareSessionResumeParams } from './ai-vault' import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' -import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-journal-ownership' +import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-host-refusal' import { configureAiVaultSessionSources, listAiVaultSessions, diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 4b2c11bab1e1..b4169145902b 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -17,7 +17,7 @@ import { assertLegacyAiVaultResumeAllowed, projectStructuredAiVaultSessions } from '../../../ai-vault/structured-session-ownership' -import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-journal-ownership' +import { ensureStructuredAgentSessionHostUnlessRefused } from '../../structured-agent-session-host-refusal' import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams, diff --git a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts index f8ae5ab38fbe..0bed563af38b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-gate.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-gate.ts @@ -13,7 +13,7 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host' import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types' import type { RpcContext } from '../core' -import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-journal-ownership' +import { structuredAgentSessionHostRefusal } from '../../structured-agent-session-host-refusal' import { supportsStructuredAgentSessionCapability, supportsStructuredAgentSessions @@ -72,9 +72,8 @@ export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSe } /** - * The host, or why there is none. A process whose chats another Orca owns, or whose journal would - * not open, says so under every getter — cleanup included: nothing here can stop a child it never - * started, and the words tell the user where their chats are. + * The host, or why there is none. A process whose journal would not open says so under every + * getter — cleanup included: nothing here can stop a child it never started. */ function requireHostOrRefusal(): StructuredAgentSessionHost { const host = getStructuredAgentSessionHost() diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index 45ce544dc295..9cfe31251b6e 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -1,9 +1,5 @@ import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' import { watchRuntimeMetadataOwnership } from '../runtime-metadata-ownership-watch' -import { - claimStructuredAgentSessionJournal, - onStructuredAgentSessionJournalOwned -} from '../structured-agent-session-journal-ownership' import type { RpcTransport } from '../rpc/transport' import { UnixSocketTransport } from '../rpc/unix-socket-transport' import { WebSocketTransport } from '../rpc/ws-transport' @@ -113,29 +109,8 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { this.activeTransports = activeTransports this.transports = transportsMeta - if (!this.ownsStructuredChats()) { - // Why: only the process owning this profile's chats is discoverable; the owner quitting - // hands the lock here, and this publishes then. - this.stopWaitingForJournalOwnership = onStructuredAgentSessionJournalOwned(() => { - this.stopWaitingForJournalOwnership?.() - this.stopWaitingForJournalOwnership = null - if (this.activeTransports.length === 0) { - return - } - try { - this.publishMetadata() - } catch (error) { - console.error( - '[runtime] Publishing runtime metadata after taking chat ownership failed:', - error - ) - } - }) - return - } - try { - this.publishMetadata() + this.writeMetadata() } catch (error) { // Why: a runtime that can't publish metadata is invisible to the CLI — close transports rather than run undiscoverable. this.activeTransports = [] @@ -143,26 +118,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) throw error } - } - - /** Taken before `orca-runtime.json` is written: a process that does not own the chats never - * advertises itself as the one that does. */ - private ownsStructuredChats(): boolean { - if (this.journalStateDirectory === undefined) { - return true - } - try { - return claimStructuredAgentSessionJournal(this.journalStateDirectory) !== null - } catch (error) { - // A lock file that will not open proves no ownership: the runtime runs undiscovered until - // the claim's retry takes the lock, and chats report the failure on their own open. - console.error('[runtime] Claiming the chat journal owner lock failed:', error) - return false - } - } - private publishMetadata(): void { - this.writeMetadata() this.metadataOwnershipWatch = watchRuntimeMetadataOwnership({ userDataPath: this.userDataPath, ownedPid: this.pid, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts index 839ad2bce297..4923dec1dec1 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts @@ -50,12 +50,6 @@ export type OrcaRuntimeRpcServerOptions = { */ pinnedBindHost?: string webClientRoot?: string - /** - * The state directory whose structured chats this runtime serves. When set, `orca-runtime.json` - * is published only once this process holds that directory's chat journal owner lock, so the - * CLI is never pointed at a process that does not own the chats. - */ - journalStateDirectory?: string // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). keepaliveIntervalMs?: number longPollCap?: number diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts index 333245d5bdcd..f79834fbcc23 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -22,8 +22,6 @@ export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { this.transports = [] this.metadataOwnershipWatch?.stop() this.metadataOwnershipWatch = null - this.stopWaitingForJournalOwnership?.() - this.stopWaitingForJournalOwnership = null this.mobileSocketWiring = null this.detachWebSocketWiring = null const stopResults = await Promise.allSettled( diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index 0f1ac384c658..e7f56ceed13c 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -41,8 +41,6 @@ export class RuntimeRpcState { protected readonly exposeNetworkByDefault: boolean protected readonly pinnedBindHost: string | null protected readonly webClientRoot: string | undefined - protected readonly journalStateDirectory: string | undefined - protected stopWaitingForJournalOwnership: (() => void) | null = null // Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once. protected wsBoundHost: string | null = null // Why: STA-2370 — in-flight widen so concurrent pairing requests share a single rebind. @@ -105,7 +103,6 @@ export class RuntimeRpcState { exposeNetworkByDefault = false, pinnedBindHost, webClientRoot, - journalStateDirectory, keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, longPollCap = LONG_POLL_CAP, metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS, @@ -122,7 +119,6 @@ export class RuntimeRpcState { this.exposeNetworkByDefault = exposeNetworkByDefault this.pinnedBindHost = pinnedBindHost ?? null this.webClientRoot = webClientRoot - this.journalStateDirectory = journalStateDirectory this.keepaliveIntervalMs = keepaliveIntervalMs this.longPollCap = longPollCap this.metadataOwnershipPollMs = metadataOwnershipPollMs diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts new file mode 100644 index 000000000000..d98e2d136216 --- /dev/null +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -0,0 +1,374 @@ +// A process whose chat journal will not open refuses every chat, leaves the file alone, and +// otherwise starts as usual: terminals, tabs and session history go on without a host. + +import { createHash } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' +import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' +import { + JournalHostDatabase, + journalDatabasePath +} from '../native-chat/agent-session-journal/journal-host-database' +import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../native-chat/agent-session-journal/journal-open-failure' +import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' +import Database from '../sqlite/sync-database' +import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' +import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' +import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' +import type { RpcContext } from './rpc/core' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +let root: string + +// An in-process caller: the same build as the host, so the gate asks it for no capability. +// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. +const IN_PROCESS = {} as RpcContext + +function install(): ReturnType { + return ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) +} + +/** The refusal as the gate throws it for every structured request. */ +function gateRefusal(): { reason: unknown; message: string } { + try { + requireStructuredCleanupHost(IN_PROCESS) + } catch (error) { + if (isAgentSessionRefusalError(error)) { + return { reason: error.refusal.details?.reason, message: error.refusal.message } + } + throw error + } + throw new Error('the gate admitted the request') +} + +async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-host-refusal-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('a process whose journal will not open', () => { + // T-corrupt-open: the error surfaces, every chat says it cannot be loaded, and nothing is + // renamed, deleted or rebuilt. + it('refuses every chat and leaves a damaged file exactly as it is', async () => { + const path = journalDatabasePath(root) + await writeFile(path, 'not a database '.repeat(512)) + const before = await digest(path) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(install()).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + expect(await digest(path)).toBe(before) + expect(existsSync(`${path}-wal`)).toBe(false) + expect(existsSync(`${path}-shm`)).toBe(false) + + // Once a person moves the file aside, the next request installs. + await unlink(path) + await expect(install()).resolves.toBeDefined() + expect(gateRefusal).toThrow('the gate admitted the request') + }) + + // T5: a newer build's database is refused as one that can clear, and left byte-identical. + it('refuses a database a newer Orca wrote, and leaves it byte-identical', async () => { + const path = journalDatabasePath(root) + const seeded = new Database(path) + seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + seeded.close() + const before = await digest(path) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + await expect(install()).rejects.toMatchObject({ + refusal: { message: JOURNAL_NEWER_SCHEMA_MESSAGE, details: { reason: 'journalUnavailable' } } + }) + expect(gateRefusal()).toEqual({ + reason: 'journalUnavailable', + message: JOURNAL_NEWER_SCHEMA_MESSAGE + }) + expect(await digest(path)).toBe(before) + }) +}) + +describe('logging a journal that will not open', () => { + const OPEN_FAILED = '[structured-agent-session] opening the chat journal database failed' + + async function writeJunkJournal(): Promise { + const path = journalDatabasePath(root) + await rm(`${path}-wal`, { force: true }) + await rm(`${path}-shm`, { force: true }) + await writeFile(path, 'not a database '.repeat(512)) + } + + // Every chat request retries the open; the same failure each time is one log, not one per request. + it('logs a repeated failure once, with its stack, and again after an open succeeds', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const openFailureLogs = () => warn.mock.calls.filter(([message]) => message === OPEN_FAILED) + await install() + await stopStructuredAgentSessionRuntime() + await writeJunkJournal() + + for (let attempt = 0; attempt < 5; attempt += 1) { + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + } + expect(openFailureLogs()).toHaveLength(1) + expect(openFailureLogs()[0]?.[1]).toBeInstanceOf(Error) + expect(openFailureLogs()[0]?.[1]).toHaveProperty('stack', expect.stringContaining('\n')) + + await unlink(journalDatabasePath(root)) + await install() + await stopStructuredAgentSessionRuntime() + await writeJunkJournal() + await expect(install()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + expect(openFailureLogs()).toHaveLength(2) + }) +}) + +// A refused host is a no-host state for startup: the app restores terminals and tabs as usual, +// and only structured requests are refused. +describe('startup and other non-chat work without a structured host', () => { + function startupRuntime(ensureHost: () => Promise = install) { + const runtime = new OrcaRuntimeService() + const refreshPtyRecords = vi.fn(async () => new Set()) + const hydrateTabs = vi.fn(() => new Set()) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. + const internal = runtime as unknown as { + hasPersistedStructuredAgentSessionStore(): boolean + ensureStructuredAgentSessionHost(): Promise + refreshMobileSessionPtyRecords(): Promise | null> + getKnownWorkspaceSessionWorktreeIds(): Set + hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set + } + internal.hasPersistedStructuredAgentSessionStore = () => true + internal.ensureStructuredAgentSessionHost = ensureHost + internal.refreshMobileSessionPtyRecords = refreshPtyRecords + internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) + internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrateTabs + return { runtime, refreshPtyRecords, hydrateTabs } + } + + async function expectStartupWithoutHost(runtime: OrcaRuntimeService): Promise { + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() + // What `session.tabs.list` awaits before it answers a paired client. + await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() + expect(getStructuredAgentSessionHost()).toBeNull() + } + + async function writeJunkJournal(): Promise { + await writeFile(journalDatabasePath(root), 'not a database '.repeat(512)) + } + + it('goes ahead when its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime, refreshPtyRecords, hydrateTabs } = startupRuntime() + + await expectStartupWithoutHost(runtime) + + expect(refreshPtyRecords).toHaveBeenCalledOnce() + expect(hydrateTabs).toHaveBeenCalledWith('workspace-1', { + allowAttachedWindow: true, + onlyRuntimeOwnedTerminals: true + }) + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + }) + + // The inventory must say "cannot tell", never "no chats": a client culls what an answer omits, + // and the desktop then saves its chat tabs away. + describe('the session-tabs inventory', () => { + /** The worktree's frame as the renderer's own graph publication leaves it: no chat rows. */ + const WORKTREE_FRAME: RuntimeMobileSessionTabsResult = { + worktree: 'workspace-1', + publicationEpoch: 'renderer-epoch', + snapshotVersion: 1, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } + + function publishWorktreeFrame(runtime: OrcaRuntimeService): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected store, called as its graph publication does. + const internal = runtime as unknown as { + storeMobileSessionSnapshot(worktreeId: string, snapshot: unknown): unknown + } + internal.storeMobileSessionSnapshot('workspace-1', WORKTREE_FRAME) + } + + async function listInventory(runtime: OrcaRuntimeService) { + await runtime.restoreStructuredAgentSessionTabs() + return runtime.listAllMobileSessionTabs() + } + + it('marks chats unverifiable when its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toEqual([ + expect.objectContaining({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + ]) + }) + + // A tap's reply goes straight back to the tapping client, not through the list. + it.each(['caller', 'clients'])( + 'marks a %s navigation reply unverifiable while its journal will not open', + async (navigation) => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + await listInventory(runtime) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected navigation step, called as `session.tabs.activate` does. + const internal = runtime as unknown as { + applyMobileSessionTabNavigation( + snapshot: RuntimeMobileSessionTabsResult, + activeTabId: string, + navigation: RuntimeNavigationTarget, + clientNavigationId?: string + ): RuntimeMobileSessionTabsResult + } + + const reply = internal.applyMobileSessionTabNavigation( + WORKTREE_FRAME, + 'agent-session:claude-1', + navigation, + 'phone-1' + ) + + expect(reply).toMatchObject({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) + } + ) + + it('lists no chats as a real answer once there is a host', async () => { + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + + const frames = await listInventory(runtime) + + expect(frames).toHaveLength(1) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(getStructuredAgentSessionHost()).not.toBeNull() + }) + + // Subscribers still hold the frames that said "cannot tell"; the restore that can tell pushes. + it('pushes the chats without the mark once a later list has a host', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const { runtime } = startupRuntime() + publishWorktreeFrame(runtime) + expect((await listInventory(runtime))[0]).toMatchObject({ agentSessionsUnverifiable: true }) + const pushed: RuntimeMobileSessionTabsResult[] = [] + const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) + + // Once a person moves the file aside, a chat request installs the host. + await unlink(journalDatabasePath(root)) + const host = await install() + vi.spyOn(host, 'listSessionTabs').mockReturnValue([ + { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' } + ]) + vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) + const frames = await listInventory(runtime) + unsubscribe() + + const chats = [expect.objectContaining({ type: 'agent-session', sessionId: 'claude-1' })] + expect(frames).toEqual([expect.objectContaining({ tabs: chats })]) + expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') + expect(pushed.at(-1)?.tabs).toEqual(chats) + expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') + }) + }) + + it('lets a terminal resume command through while its journal will not open', async () => { + await writeJunkJournal() + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + // The check `terminal.send` and `session.tabs.createTerminal` run before typing a resume. + await expect( + assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { + await install() + }) + ).resolves.toBeUndefined() + await expect( + assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { + throw new Error('the record store would not open') + }) + ).rejects.toThrow('the record store would not open') + expect(gateRefusal().reason).toBe('journalCorrupt') + }) + + it('still fails on an install error that refuses nothing', async () => { + const { runtime, refreshPtyRecords } = startupRuntime(async () => { + throw new Error('the record store would not open') + }) + + await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).rejects.toThrow( + 'the record store would not open' + ) + expect(refreshPtyRecords).not.toHaveBeenCalled() + }) +}) + +describe('stopping', () => { + it('keeps the connection until a journal close that failed is retried and goes through', async () => { + await install() + const close = JournalHostDatabase.prototype.close + let connectionClose: ReturnType | null = null + const journalClose = vi + .spyOn(JournalHostDatabase.prototype, 'close') + .mockImplementation(function (this: JournalHostDatabase) { + // The connection's first close fails, as a native close can before it completes. + connectionClose ??= vi.spyOn(this.db, 'close').mockImplementationOnce(() => { + throw new Error('unable to close due to unfinalized statements') + }) + close.call(this) + }) + const journal = () => journalClose.mock.contexts[0] + + await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow('unable to close') + expect(journal()).toHaveProperty('isClosed', false) + + await stopStructuredAgentSessionRuntime() + expect(connectionClose).toHaveBeenCalledTimes(2) + expect(journal()).toHaveProperty('isClosed', true) + }) +}) diff --git a/src/main/runtime/structured-agent-session-host-refusal.ts b/src/main/runtime/structured-agent-session-host-refusal.ts new file mode 100644 index 000000000000..3ab3652f56ac --- /dev/null +++ b/src/main/runtime/structured-agent-session-host-refusal.ts @@ -0,0 +1,47 @@ +// Why structured chats are refused in this process: its chat journal would not open at install +// (damaged, written by a newer Orca, or unavailable). Everything else goes on without a host. + +import { + isAgentSessionRefusalError, + type AgentSessionRefusalError +} from '../../shared/agent-session-wire-refusals' + +let installRefusal: AgentSessionRefusalError | null = null + +/** Why structured chats are refused in this process, or null when nothing refuses them. */ +export function structuredAgentSessionHostRefusal(): AgentSessionRefusalError | null { + return installRefusal +} + +/** Whether `error` is the refusal structured requests are getting right now. */ +export function isStructuredAgentSessionHostRefusal(error: unknown): boolean { + const refusal = installRefusal + return ( + refusal !== null && + isAgentSessionRefusalError(error) && + error.refusal.code === refusal.refusal.code && + error.refusal.message === refusal.refusal.message + ) +} + +/** For work that goes on without chats: the refusal chats are getting leaves this process with no + * host, and any other install failure still throws. */ +export async function ensureStructuredAgentSessionHostUnlessRefused( + ensureHost: () => Promise +): Promise { + try { + await ensureHost() + } catch (error) { + if (!isStructuredAgentSessionHostRefusal(error)) { + throw error + } + } +} + +/** Set when the journal database would not open; cleared by a later install. The install retries + * on the next call, so a refusal that can clear does. */ +export function recordStructuredAgentSessionHostInstallRefusal( + refusal: AgentSessionRefusalError | null +): void { + installRefusal = refusal +} diff --git a/src/main/runtime/structured-agent-session-integration.test.ts b/src/main/runtime/structured-agent-session-integration.test.ts index e38974240c87..cd8939b93888 100644 --- a/src/main/runtime/structured-agent-session-integration.test.ts +++ b/src/main/runtime/structured-agent-session-integration.test.ts @@ -427,7 +427,7 @@ describe('a structured codex session over agentSession.*', () => { fence: 0, options: { filePath: rollout } }) - // The previous process exits, and its owner lock with it. + // The previous process exits, closing its database. await journals.closeAll() const created = await ok<{ page: { items: AgentJournalRenderItem[] } }>( diff --git a/src/main/runtime/structured-agent-session-journal-open.ts b/src/main/runtime/structured-agent-session-journal-open.ts index 5207273e8e1e..6972a18a7960 100644 --- a/src/main/runtime/structured-agent-session-journal-open.ts +++ b/src/main/runtime/structured-agent-session-journal-open.ts @@ -1,12 +1,8 @@ -// Opening the chat journal database under this process's owner lock, for the host install. +// Opening the chat journal database for the host install. import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' -import { - claimStructuredAgentSessionJournal, - recordStructuredAgentSessionHostInstallRefusal, - structuredAgentSessionJournalOwnerRefusal -} from './structured-agent-session-journal-ownership' +import { recordStructuredAgentSessionHostInstallRefusal } from './structured-agent-session-host-refusal' // Every chat request retries a failed open, so each distinct failure is logged once, with its stack. let lastLoggedOpenFailure: string | null = null @@ -23,18 +19,16 @@ function logOpenFailureOnce(error: unknown): void { console.warn('[structured-agent-session] opening the chat journal database failed', error) } -/** The journal database, opened only under this process's owner lock. A refusal is recorded for - * the gate and thrown to the caller; the next install tries again. A lock file that cannot be - * opened at all refuses like the database it guards, until the claim's retry takes the lock. */ -export function openOwnedJournalDatabase(stateDirectory: string): JournalHostDatabase { +/** The journal database. A refusal is recorded for the gate and thrown to the caller; the next + * install tries again. */ +export function openStructuredAgentSessionJournalDatabase( + stateDirectory: string +): JournalHostDatabase { try { - const lock = claimStructuredAgentSessionJournal(stateDirectory) - if (lock) { - const opened = JournalHostDatabase.open(lock) - recordStructuredAgentSessionHostInstallRefusal(null) - lastLoggedOpenFailure = null - return opened - } + const opened = JournalHostDatabase.open(stateDirectory) + recordStructuredAgentSessionHostInstallRefusal(null) + lastLoggedOpenFailure = null + return opened } catch (error) { logOpenFailureOnce(error) // Nothing is renamed, deleted or rebuilt: the file is left exactly as it is. @@ -42,8 +36,4 @@ export function openOwnedJournalDatabase(stateDirectory: string): JournalHostDat recordStructuredAgentSessionHostInstallRefusal(refusal) throw refusal } - throw ( - structuredAgentSessionJournalOwnerRefusal() ?? - new Error('the chat journal owner lock was refused') - ) } diff --git a/src/main/runtime/structured-agent-session-journal-ownership.test.ts b/src/main/runtime/structured-agent-session-journal-ownership.test.ts deleted file mode 100644 index 7cb97095669f..000000000000 --- a/src/main/runtime/structured-agent-session-journal-ownership.test.ts +++ /dev/null @@ -1,758 +0,0 @@ -// One Orca process owns a profile's structured chats. Another on the same profile is refused -// with words that say what to do, is never advertised to the CLI, and takes over when the owner -// quits. An owner whose journal will not open refuses every chat and leaves the file alone. - -import { createHash } from 'node:crypto' -import { existsSync } from 'node:fs' -import { mkdtemp, readFile, rm, unlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { isAgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' -import { JOURNAL_DB_SCHEMA_VERSION } from '../native-chat/agent-session-journal/journal-database-schema' -import { - JournalHostDatabase, - journalDatabasePath -} from '../native-chat/agent-session-journal/journal-host-database' -import { JOURNAL_OWNER_LOCK_FILE } from '../native-chat/agent-session-journal/journal-owner-lock' -import { - holdJournalOwnerLockInChild, - probeJournalOwnerLockInChild, - type JournalOwnerLockHolder -} from '../native-chat/agent-session-journal/journal-owner-lock-test-support' -import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../native-chat/agent-session-journal/journal-open-failure' -import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' -import Database from '../sqlite/sync-database' -import { agentModelCatalogStore } from '../native-chat/agent-model-catalog/agent-model-catalog-store' -import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' -import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import { AgentSessionRecordStore } from './agent-session-record-store' -import { OrcaRuntimeService } from './orca-runtime' -import { requireStructuredCleanupHost } from './rpc/methods/structured-agent-session-gate' -import { assertLegacyAiVaultResumeCommandAllowed } from '../ai-vault/structured-session-ownership' -import type { RpcContext } from './rpc/core' -import { getRuntimeMetadataPath } from '../../shared/runtime-bootstrap' -import { readRuntimeMetadata } from './runtime-metadata' -import { OrcaRuntimeRpcServer } from './runtime-rpc' -import { - JOURNAL_OWNER_REFUSAL_MESSAGES, - onStructuredAgentSessionJournalOwned, - releaseStructuredAgentSessionJournal, - setJournalOwnerProcessKind, - type JournalOwnerProcessKind -} from './structured-agent-session-journal-ownership' -import { - ensureStructuredAgentSessionHost, - stopStructuredAgentSessionRuntime -} from './structured-agent-session-runtime' - -let root: string -let holder: JournalOwnerLockHolder | null = null -// A refused startup waits for ownership; a later test's claim must not wake an earlier runtime. -const stopAwaitingOwnership: (() => void)[] = [] - -// An in-process caller: the same build as the host, so the gate asks it for no capability. -// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the cleanup gate reads only `clientKind` and `clientCapabilities`. -const IN_PROCESS = {} as RpcContext - -function install(): ReturnType { - return ensureStructuredAgentSessionHost({ - stateDirectory: root, - hostId: 'local', - claimKeyId: 'key-1', - resolveWorkspacePath: async () => root, - resolveEnvironment: async () => ({}), - resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) - }) -} - -/** The refusal as the gate throws it for every structured request. */ -function gateRefusal(): { reason: unknown; message: string } { - try { - requireStructuredCleanupHost(IN_PROCESS) - } catch (error) { - if (isAgentSessionRefusalError(error)) { - return { reason: error.refusal.details?.reason, message: error.refusal.message } - } - throw error - } - throw new Error('the gate admitted the request') -} - -async function digest(path: string): Promise { - return createHash('sha256') - .update(await readFile(path)) - .digest('hex') -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-ownership-')) -}) - -afterEach(async () => { - stopAwaitingOwnership.splice(0).forEach((stop) => stop()) - await holder?.kill() - holder = null - await stopStructuredAgentSessionRuntime().catch(() => undefined) - releaseStructuredAgentSessionJournal() - setJournalOwnerProcessKind('packaged') - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) -}) - -describe('a second process on the same profile', () => { - // T-dev-refusal, in its dev, packaged and orcad variants. - it.each(['dev-desktop', 'packaged', 'orcad'])( - 'is refused with the %s words, and never opens the database', - async (kind) => { - holder = await holdJournalOwnerLockInChild(root) - setJournalOwnerProcessKind(kind) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - await expect(install()).rejects.toMatchObject({ - refusal: { - code: 'agent_session_journal_unreadable', - message: JOURNAL_OWNER_REFUSAL_MESSAGES[kind], - details: { reason: 'journalOwnedElsewhere', processKind: kind } - } - }) - expect(gateRefusal()).toEqual({ - reason: 'journalOwnedElsewhere', - message: JOURNAL_OWNER_REFUSAL_MESSAGES[kind] - }) - expect(getStructuredAgentSessionHost()).toBeNull() - expect(existsSync(journalDatabasePath(root))).toBe(false) - expect(console.warn).toHaveBeenCalledWith( - expect.stringContaining('agent-session-journal.owner; structured chats are read-refused') - ) - } - ) - - // T-B1 / N-R3.3: when the owner dies, the refused process takes the lock itself and its next - // request runs the full install — not a flag flip. - it('takes over once the owner dies, with a full install', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await expect(install()).rejects.toMatchObject({ - refusal: { details: { reason: 'journalOwnedElsewhere' } } - }) - - await holder.kill() - holder = null - - // Its retry took the lock: no process refuses it any more, and nothing is installed yet. - await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) - await expect(install()).resolves.toBeDefined() - expect(getStructuredAgentSessionHost()).not.toBeNull() - expect(existsSync(journalDatabasePath(root))).toBe(true) - expect(await probeJournalOwnerLockInChild(root)).toBe('refused') - }) - - // Discovery points the CLI only at the process that owns the chats. - it('is not published to the CLI until it owns the chats', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath: root, - journalStateDirectory: root - }) - try { - await server.start() - expect(readRuntimeMetadata(root)).toBeNull() - - await holder.kill() - holder = null - await vi.waitFor(() => expect(readRuntimeMetadata(root)?.pid).toBe(process.pid), { - timeout: 10_000 - }) - } finally { - await server.stop() - } - }) - - // Not opening the lock file proves no ownership: the process that does own the chats stays the - // one the CLI finds, until this one's retry takes the lock. - it('does not publish over the owner while its lock file will not open', async () => { - const lockPath = join(root, JOURNAL_OWNER_LOCK_FILE) - await writeFile(lockPath, 'not a database '.repeat(512)) - const metadataPath = getRuntimeMetadataPath(root) - await writeFile(metadataPath, JSON.stringify({ pid: 999_999, runtimeId: 'the-owner' })) - const before = await digest(metadataPath) - vi.spyOn(console, 'error').mockImplementation(() => undefined) - const server = new OrcaRuntimeRpcServer({ - runtime: new OrcaRuntimeService(), - userDataPath: root, - journalStateDirectory: root - }) - try { - await server.start() - expect(await digest(metadataPath)).toBe(before) - - await unlink(lockPath) - await vi.waitFor(() => expect(readRuntimeMetadata(root)?.pid).toBe(process.pid), { - timeout: 10_000 - }) - } finally { - await server.stop() - } - }) -}) - -describe('the owner, when its journal will not open', () => { - // T-corrupt-open: the error surfaces, every chat says it cannot be loaded, and nothing is - // renamed, deleted or rebuilt. - it('refuses every chat and leaves a damaged file exactly as it is', async () => { - const path = journalDatabasePath(root) - await writeFile(path, 'not a database '.repeat(512)) - const before = await digest(path) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - await expect(install()).rejects.toMatchObject({ - refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } - }) - expect(gateRefusal()).toEqual({ - reason: 'journalCorrupt', - message: 'Unable to load this chat.' - }) - expect(await digest(path)).toBe(before) - expect(existsSync(`${path}-wal`)).toBe(false) - expect(existsSync(`${path}-shm`)).toBe(false) - - // Once a person moves the file aside, the next request installs. - await unlink(path) - await expect(install()).resolves.toBeDefined() - expect(gateRefusal).toThrow('the gate admitted the request') - }) - - // T5: a newer build's database is refused as one that can clear, and left byte-identical. - it('refuses a database a newer Orca wrote, and leaves it byte-identical', async () => { - const path = journalDatabasePath(root) - const seeded = new Database(path) - seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - seeded.close() - const before = await digest(path) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - await expect(install()).rejects.toMatchObject({ - refusal: { message: JOURNAL_NEWER_SCHEMA_MESSAGE, details: { reason: 'journalUnavailable' } } - }) - expect(gateRefusal()).toEqual({ - reason: 'journalUnavailable', - message: JOURNAL_NEWER_SCHEMA_MESSAGE - }) - expect(await digest(path)).toBe(before) - }) -}) - -describe('logging a journal that will not open', () => { - const OPEN_FAILED = '[structured-agent-session] opening the chat journal database failed' - - async function writeJunkJournal(): Promise { - const path = journalDatabasePath(root) - await rm(`${path}-wal`, { force: true }) - await rm(`${path}-shm`, { force: true }) - await writeFile(path, 'not a database '.repeat(512)) - } - - // Every chat request retries the open; the same failure each time is one log, not one per request. - it('logs a repeated failure once, with its stack, and again after an open succeeds', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const openFailureLogs = () => warn.mock.calls.filter(([message]) => message === OPEN_FAILED) - await install() - await stopStructuredAgentSessionRuntime() - await writeJunkJournal() - - for (let attempt = 0; attempt < 5; attempt += 1) { - await expect(install()).rejects.toMatchObject({ - refusal: { details: { reason: 'journalCorrupt' } } - }) - } - expect(openFailureLogs()).toHaveLength(1) - expect(openFailureLogs()[0]?.[1]).toBeInstanceOf(Error) - expect(openFailureLogs()[0]?.[1]).toHaveProperty('stack', expect.stringContaining('\n')) - - await unlink(journalDatabasePath(root)) - await install() - await stopStructuredAgentSessionRuntime() - await writeJunkJournal() - await expect(install()).rejects.toMatchObject({ - refusal: { details: { reason: 'journalCorrupt' } } - }) - expect(openFailureLogs()).toHaveLength(2) - }) -}) - -// A refused host is a no-host state for startup: the app restores terminals and tabs as usual, -// and only structured requests are refused. -describe('startup and other non-chat work without a structured host', () => { - function startupRuntime(ensureHost: () => Promise = install) { - const runtime = new OrcaRuntimeService() - const refreshPtyRecords = vi.fn(async () => new Set()) - const hydrateTabs = vi.fn(() => new Set()) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: these are the runtime's own protected members; the test roots the host at `root` and stubs the PTY daemon. - const internal = runtime as unknown as { - hasPersistedStructuredAgentSessionStore(): boolean - ensureStructuredAgentSessionHost(): Promise - refreshMobileSessionPtyRecords(): Promise | null> - getKnownWorkspaceSessionWorktreeIds(): Set - hydrateHeadlessMobileSessionTabsFromWorkspaceSession(): Set - stopAwaitingStructuredAgentSessionJournal: (() => void) | null - } - internal.hasPersistedStructuredAgentSessionStore = () => true - internal.ensureStructuredAgentSessionHost = ensureHost - internal.refreshMobileSessionPtyRecords = refreshPtyRecords - internal.getKnownWorkspaceSessionWorktreeIds = () => new Set(['workspace-1']) - internal.hydrateHeadlessMobileSessionTabsFromWorkspaceSession = hydrateTabs - stopAwaitingOwnership.push(() => internal.stopAwaitingStructuredAgentSessionJournal?.()) - return { runtime, refreshPtyRecords, hydrateTabs } - } - - async function expectStartupWithoutHost(runtime: OrcaRuntimeService): Promise { - await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).resolves.toBeUndefined() - // What `session.tabs.list` awaits before it answers a paired client. - await expect(runtime.restoreStructuredAgentSessionTabs()).resolves.toBeUndefined() - expect(getStructuredAgentSessionHost()).toBeNull() - } - - it('goes ahead while another process owns the chats', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, refreshPtyRecords, hydrateTabs } = startupRuntime() - - await expectStartupWithoutHost(runtime) - - expect(refreshPtyRecords).toHaveBeenCalledOnce() - expect(hydrateTabs).toHaveBeenCalledWith('workspace-1', { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - expect(gateRefusal().reason).toBe('journalOwnedElsewhere') - }) - - it('goes ahead when the owner cannot open its journal', async () => { - await writeFile(journalDatabasePath(root), 'not a database '.repeat(512)) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, refreshPtyRecords, hydrateTabs } = startupRuntime() - - await expectStartupWithoutHost(runtime) - - expect(refreshPtyRecords).toHaveBeenCalledOnce() - expect(hydrateTabs).toHaveBeenCalledWith('workspace-1', { - allowAttachedWindow: true, - onlyRuntimeOwnedTerminals: true - }) - expect(gateRefusal()).toEqual({ - reason: 'journalCorrupt', - message: 'Unable to load this chat.' - }) - }) - - // Not a database, as a sync or restore tool can leave it: SQLite answers errcode 26 on its claim. - it('goes ahead when the owner lock file cannot be opened', async () => { - await writeFile(join(root, JOURNAL_OWNER_LOCK_FILE), 'not a database '.repeat(512)) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, refreshPtyRecords } = startupRuntime() - - await expectStartupWithoutHost(runtime) - - expect(refreshPtyRecords).toHaveBeenCalledOnce() - expect(gateRefusal()).toEqual({ - reason: 'journalCorrupt', - message: 'Unable to load this chat.' - }) - - // The claim keeps retrying; once the file opens, the takeover installs as it does after an owner quits. - await unlink(join(root, JOURNAL_OWNER_LOCK_FILE)) - await vi.waitFor(() => expect(getStructuredAgentSessionHost()).not.toBeNull(), { - timeout: 10_000 - }) - }) - - function heldOnce(mock: ReturnType): () => void { - let release!: () => void - const held = new Promise((resolve) => (release = resolve)) - mock.mockImplementationOnce(async () => { - await held - return new Set() - }) - return release - } - - // The inventory must say "cannot tell", never "no chats": a client culls what an answer omits, - // and the desktop then saves its chat tabs away. - describe('the session-tabs inventory', () => { - /** The worktree's frame as the renderer's own graph publication leaves it: no chat rows. */ - const WORKTREE_FRAME: RuntimeMobileSessionTabsResult = { - worktree: 'workspace-1', - publicationEpoch: 'renderer-epoch', - snapshotVersion: 1, - activeGroupId: null, - activeTabId: null, - activeTabType: null, - tabs: [] - } - - function publishWorktreeFrame(runtime: OrcaRuntimeService): void { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected store, called as its graph publication does. - const internal = runtime as unknown as { - storeMobileSessionSnapshot(worktreeId: string, snapshot: unknown): unknown - } - internal.storeMobileSessionSnapshot('workspace-1', WORKTREE_FRAME) - } - - async function listInventory(runtime: OrcaRuntimeService) { - await runtime.restoreStructuredAgentSessionTabs() - return runtime.listAllMobileSessionTabs() - } - - it('marks chats unverifiable while another process owns them', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime } = startupRuntime() - publishWorktreeFrame(runtime) - - const frames = await listInventory(runtime) - - expect(frames).toEqual([ - expect.objectContaining({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) - ]) - }) - - it('marks chats unverifiable when the owner cannot open its journal', async () => { - await writeFile(journalDatabasePath(root), 'not a database '.repeat(512)) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime } = startupRuntime() - publishWorktreeFrame(runtime) - - const frames = await listInventory(runtime) - - expect(frames).toEqual([ - expect.objectContaining({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) - ]) - }) - - // A tap's reply goes straight back to the tapping client, not through the list. - it.each(['caller', 'clients'])( - 'marks a %s navigation reply unverifiable while another process owns the chats', - async (navigation) => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime } = startupRuntime() - publishWorktreeFrame(runtime) - await listInventory(runtime) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime's own protected navigation step, called as `session.tabs.activate` does. - const internal = runtime as unknown as { - applyMobileSessionTabNavigation( - snapshot: RuntimeMobileSessionTabsResult, - activeTabId: string, - navigation: RuntimeNavigationTarget, - clientNavigationId?: string - ): RuntimeMobileSessionTabsResult - } - - const reply = internal.applyMobileSessionTabNavigation( - WORKTREE_FRAME, - 'agent-session:claude-1', - navigation, - 'phone-1' - ) - - expect(reply).toMatchObject({ worktree: 'workspace-1', agentSessionsUnverifiable: true }) - } - ) - - it('lists no chats as a real answer once the owner has a host', async () => { - const { runtime } = startupRuntime() - publishWorktreeFrame(runtime) - - const frames = await listInventory(runtime) - - expect(frames).toHaveLength(1) - expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') - expect(getStructuredAgentSessionHost()).not.toBeNull() - }) - - it('restores on its own after a takeover, pushing the chats without the mark', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const reconciled = vi.fn(async () => undefined) - // The real install; its host's open conversations stand in for the chats its readable - // restore reopens. - const { runtime, refreshPtyRecords } = startupRuntime(async () => { - const host = await install() - vi.spyOn(host, 'listSessionTabs').mockReturnValue([ - { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' }, - { workspaceId: 'workspace-1', sessionId: 'codex-1', agent: 'codex' } - ]) - vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) - vi.spyOn(host, 'reconcileRestartLeases').mockImplementation(reconciled) - return host - }) - publishWorktreeFrame(runtime) - expect((await listInventory(runtime))[0]).toMatchObject({ agentSessionsUnverifiable: true }) - // A paired phone's subscription: nothing on the desktop lists or opens a chat after this. - const pushed: RuntimeMobileSessionTabsResult[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) - - await holder.kill() - holder = null - - const chats = [ - expect.objectContaining({ type: 'agent-session', sessionId: 'claude-1', agent: 'claude' }), - expect.objectContaining({ type: 'agent-session', sessionId: 'codex-1', agent: 'codex' }) - ] - await vi.waitFor(() => expect(pushed.at(-1)?.tabs).toEqual(chats), { timeout: 10_000 }) - unsubscribe() - expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') - expect(getStructuredAgentSessionHost()).not.toBeNull() - expect(reconciled).toHaveBeenCalledOnce() - expect(refreshPtyRecords).toHaveBeenCalledTimes(2) - const frames = await runtime.listAllMobileSessionTabs() - expect(frames).toEqual([expect.objectContaining({ tabs: chats })]) - expect(frames[0]).not.toHaveProperty('agentSessionsUnverifiable') - expect(reconciled).toHaveBeenCalledOnce() - }) - - /** A startup whose host has a Claude chat once the takeover installs it. */ - function takeoverRuntime() { - const reconciled = vi.fn(async () => undefined) - const started = startupRuntime(async () => { - const host = await install() - vi.spyOn(host, 'listSessionTabs').mockReturnValue([ - { workspaceId: 'workspace-1', sessionId: 'claude-1', agent: 'claude' } - ]) - vi.spyOn(host, 'setSessionTabVisibility').mockResolvedValue(undefined) - vi.spyOn(host, 'reconcileRestartLeases').mockImplementation(reconciled) - return host - }) - publishWorktreeFrame(started.runtime) - return { ...started, reconciled } - } - - const CLAUDE_CHAT = expect.objectContaining({ type: 'agent-session', sessionId: 'claude-1' }) - - it('pushes the chats when a list lands during the refused startup, after the takeover', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, refreshPtyRecords, reconciled } = takeoverRuntime() - const releaseRefresh = heldOnce(refreshPtyRecords) - const pushed: RuntimeMobileSessionTabsResult[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) - const startup = runtime.prepareStructuredAgentSessionStartupRestoration() - await vi.waitFor(() => expect(refreshPtyRecords).toHaveBeenCalledOnce()) - - await holder.kill() - holder = null - await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) - // Still on the refused startup's memo, so this list cannot tell. - const listed = listInventory(runtime) - releaseRefresh() - await startup - - expect((await listed)[0]).toMatchObject({ agentSessionsUnverifiable: true, tabs: [] }) - await vi.waitFor(() => expect(pushed.at(-1)?.tabs).toEqual([CLAUDE_CHAT]), { - timeout: 10_000 - }) - unsubscribe() - expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') - expect(reconciled).toHaveBeenCalledOnce() - }) - - it('pushes the chats when a list heals them during the takeover install', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, refreshPtyRecords } = takeoverRuntime() - expect((await listInventory(runtime))[0]).toMatchObject({ agentSessionsUnverifiable: true }) - const releaseRefresh = heldOnce(refreshPtyRecords) - const pushed: RuntimeMobileSessionTabsResult[] = [] - const unsubscribe = runtime.onMobileSessionTabsChanged((frame) => pushed.push(frame)) - - await holder.kill() - holder = null - // The takeover's own startup restoration is refreshing PTYs, with its host installed. - await vi.waitFor(() => expect(refreshPtyRecords).toHaveBeenCalledTimes(2), { - timeout: 10_000 - }) - const listed = listInventory(runtime) - releaseRefresh() - - expect((await listed)[0]?.tabs).toEqual([CLAUDE_CHAT]) - await vi.waitFor(() => expect(pushed.at(-1)?.tabs).toEqual([CLAUDE_CHAT]), { - timeout: 10_000 - }) - unsubscribe() - expect(pushed.at(-1)).not.toHaveProperty('agentSessionsUnverifiable') - }) - }) - - describe('quitting while refused', () => { - function holdCatalogFlush(): () => void { - let release!: () => void - const held = new Promise((resolve) => (release = resolve)) - vi.spyOn(agentModelCatalogStore, 'flushPersistence').mockImplementationOnce(() => held) - return release - } - - it('does not take the lock once the stop has begun', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime } = startupRuntime() - await runtime.prepareStructuredAgentSessionStartupRestoration() - const owned = vi.fn() - stopAwaitingOwnership.push(onStructuredAgentSessionJournalOwned(owned)) - const releaseFlush = holdCatalogFlush() - const stopping = stopStructuredAgentSessionRuntime() - - await holder.kill() - holder = null - // Past the longest retry delay: a retry still scheduled would have taken the lock by now. - await new Promise((resolve) => setTimeout(resolve, 5_500)) - releaseFlush() - await stopping - - expect(owned).not.toHaveBeenCalled() - expect(getStructuredAgentSessionHost()).toBeNull() - expect(existsSync(journalDatabasePath(root))).toBe(false) - }) - - it('tears down a takeover install that lands during the stop', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, refreshPtyRecords } = startupRuntime(async () => { - const host = await install() - vi.spyOn(host, 'reconcileRestartLeases').mockResolvedValue(undefined) - return host - }) - const releaseRefresh = heldOnce(refreshPtyRecords) - const startup = runtime.prepareStructuredAgentSessionStartupRestoration() - await vi.waitFor(() => expect(refreshPtyRecords).toHaveBeenCalledOnce()) - await holder.kill() - holder = null - // Taken before the quit: the takeover waits on the refused startup's PTY refresh. - await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) - const closed = vi.spyOn(JournalHostDatabase.prototype, 'close') - const releaseFlush = holdCatalogFlush() - const stopping = stopStructuredAgentSessionRuntime() - - releaseRefresh() - await startup - await vi.waitFor(() => expect(getStructuredAgentSessionHost()).not.toBeNull(), { - timeout: 10_000 - }) - releaseFlush() - await stopping - - expect(getStructuredAgentSessionHost()).toBeNull() - expect(closed).toHaveBeenCalledOnce() - }) - - // An install that finishes after a later pass cleared the registry registers the host that pass - // then tears down: no request after the stop may be handed that host. - it('leaves no host registered when a takeover install spans a teardown pass', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const { runtime, refreshPtyRecords } = startupRuntime(async () => { - const host = await install() - vi.spyOn(host, 'reconcileRestartLeases').mockResolvedValue(undefined) - return host - }) - const releaseRefresh = heldOnce(refreshPtyRecords) - const startup = runtime.prepareStructuredAgentSessionStartupRestoration() - await vi.waitFor(() => expect(refreshPtyRecords).toHaveBeenCalledOnce()) - // Only the takeover's install opens the record store, and it waits there. - const openStore = AgentSessionRecordStore.open.bind(AgentSessionRecordStore) - let releaseOpen!: () => void - const openHeld = new Promise((resolve) => (releaseOpen = resolve)) - const opened = vi - .spyOn(AgentSessionRecordStore, 'open') - .mockImplementationOnce(async (input) => { - await openHeld - return openStore(input) - }) - await holder.kill() - holder = null - await vi.waitFor(() => expect(gateRefusal().reason).toBe('hostDisabled'), { timeout: 10_000 }) - const closed = vi.spyOn(JournalHostDatabase.prototype, 'close') - const releaseFlush = holdCatalogFlush() - const stopping = stopStructuredAgentSessionRuntime() - - releaseRefresh() - await startup - await vi.waitFor(() => expect(opened).toHaveBeenCalled(), { timeout: 10_000 }) - releaseFlush() - // The second pass clears the registry and waits on the install, which then registers its host. - await new Promise((resolve) => setTimeout(resolve, 50)) - releaseOpen() - await stopping - - expect(closed).toHaveBeenCalledOnce() - expect(getStructuredAgentSessionHost()).toBeNull() - }) - }) - - it('lets a terminal resume command through while another process owns the chats', async () => { - holder = await holdJournalOwnerLockInChild(root) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - - // The check `terminal.send` and `session.tabs.createTerminal` run before typing a resume. - await expect( - assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { - await install() - }) - ).resolves.toBeUndefined() - await expect( - assertLegacyAiVaultResumeCommandAllowed('claude --resume 0f9c1d2e', async () => { - throw new Error('the record store would not open') - }) - ).rejects.toThrow('the record store would not open') - expect(gateRefusal().reason).toBe('journalOwnedElsewhere') - }) - - it('still fails on an install error that refuses nothing', async () => { - const { runtime, refreshPtyRecords } = startupRuntime(async () => { - throw new Error('the record store would not open') - }) - - await expect(runtime.prepareStructuredAgentSessionStartupRestoration()).rejects.toThrow( - 'the record store would not open' - ) - expect(refreshPtyRecords).not.toHaveBeenCalled() - }) -}) - -describe('releasing ownership', () => { - it('lets another process take the chats only after a clean stop', async () => { - await install() - expect(await probeJournalOwnerLockInChild(root)).toBe('refused') - - await stopStructuredAgentSessionRuntime() - - expect(await probeJournalOwnerLockInChild(root)).toBe('acquired') - }) - - it('keeps the chats until a journal close that failed is retried and goes through', async () => { - await install() - const close = JournalHostDatabase.prototype.close - let connectionClose: ReturnType | null = null - const journalClose = vi - .spyOn(JournalHostDatabase.prototype, 'close') - .mockImplementation(function (this: JournalHostDatabase) { - // The connection's first close fails, as a native close can before it completes. - connectionClose ??= vi.spyOn(this.db, 'close').mockImplementationOnce(() => { - throw new Error('unable to close due to unfinalized statements') - }) - close.call(this) - }) - const journal = () => journalClose.mock.contexts[0] - - await expect(stopStructuredAgentSessionRuntime()).rejects.toThrow('unable to close') - expect(journal()).toHaveProperty('isClosed', false) - expect(await probeJournalOwnerLockInChild(root)).toBe('refused') - - await stopStructuredAgentSessionRuntime() - expect(connectionClose).toHaveBeenCalledTimes(2) - expect(journal()).toHaveProperty('isClosed', true) - expect(await probeJournalOwnerLockInChild(root)).toBe('acquired') - }) -}) diff --git a/src/main/runtime/structured-agent-session-journal-ownership.ts b/src/main/runtime/structured-agent-session-journal-ownership.ts deleted file mode 100644 index 030b1d075ccc..000000000000 --- a/src/main/runtime/structured-agent-session-journal-ownership.ts +++ /dev/null @@ -1,196 +0,0 @@ -// Whether THIS process owns its profile's structured chats. -// -// The claim is taken at runtime start, before `orca-runtime.json` is published, so a process that -// does not own the chats never advertises itself as the one that does. A process that cannot take -// it, because another holds it or because the lock file will not open, serves every structured-chat -// request a refusal and keeps retrying; publishing waits for the retry to land. One claim per -// process; a claim on another state directory releases the previous one first. - -import { resolve } from 'node:path' -import type { AgentSessionJournalProcessKind } from '../../shared/agent-session-refusal-details' -import { - agentSessionRefusalError, - isAgentSessionRefusalError, - type AgentSessionRefusalError -} from '../../shared/agent-session-wire-refusals' -import { - JOURNAL_OWNER_LOCK_FILE, - retryJournalOwnerLock, - tryAcquireJournalOwnerLock, - type JournalOwnerLock -} from '../native-chat/agent-session-journal/journal-owner-lock' - -/** Which way the refused process can be told to get out of the way. */ -export type JournalOwnerProcessKind = AgentSessionJournalProcessKind - -// For logs and released clients; current clients pick their words from the reason and kind. -export const JOURNAL_OWNER_REFUSAL_MESSAGES: Record = { - 'dev-desktop': - 'Chats are open in another Orca window using this profile. Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).', - packaged: 'Chats are open in another Orca process using this profile. Quit it to use chats here.', - orcad: - 'Chats are open in another Orca process using this data folder. Stop it, or give this one its own folder (ORCA_USER_DATA).' -} - -type Refused = { - stateDirectory: string - retry: { cancel: () => void } - /** Why the latest attempt could not open the lock, or null while another process holds it. */ - failure: { error: unknown } | null -} - -let processKind: JournalOwnerProcessKind = 'packaged' -let held: JournalOwnerLock | null = null -let refused: Refused | null = null -let installRefusal: AgentSessionRefusalError | null = null -const ownedListeners = new Set<() => void>() - -export function setJournalOwnerProcessKind(kind: JournalOwnerProcessKind): void { - processKind = kind -} - -/** The held lock for `stateDirectory`, or null while another process holds it. Throws while the - * lock file will not open; either way the claim stays refused and retries. */ -export function claimStructuredAgentSessionJournal( - stateDirectory: string -): JournalOwnerLock | null { - const directory = resolve(stateDirectory) - if (held && held.stateDirectory === directory) { - return held - } - if (refused?.stateDirectory === directory) { - return refusedClaim(refused) - } - // A profile switch: the old claim goes before the new one is taken. - releaseStructuredAgentSessionJournal() - const claim: Refused = { - stateDirectory: directory, - retry: { cancel: () => undefined }, - failure: null - } - // Every attempt, the retry's too, records why it failed: not opening the lock is not owning it. - const attempt = (lockDirectory: string): JournalOwnerLock | null => { - try { - const lock = tryAcquireJournalOwnerLock(lockDirectory) - claim.failure = null - return lock - } catch (error) { - claim.failure = { error } - return null - } - } - const lock = attempt(directory) - if (lock) { - adopt(lock) - return lock - } - if (!claim.failure) { - console.warn( - `[structured-agent-session] another process owns ${directory}/${JOURNAL_OWNER_LOCK_FILE}; structured chats are read-refused in this process` - ) - } - claim.retry = retryJournalOwnerLock({ - stateDirectory: directory, - onAcquired: adopt, - acquire: attempt - }) - refused = claim - return refusedClaim(claim) -} - -function refusedClaim(claim: Refused): null { - if (claim.failure) { - throw claim.failure.error - } - return null -} - -/** Whether this process is waiting on its claim, whichever way it was refused. */ -export function isStructuredAgentSessionJournalClaimRefused(): boolean { - return refused !== null -} - -/** What every structured-chat request gets while another process owns the chats. A lock file - * that will not open refuses through the install instead, like the database it guards. */ -export function structuredAgentSessionJournalOwnerRefusal(): AgentSessionRefusalError | null { - if (!refused || refused.failure) { - return null - } - return agentSessionRefusalError( - 'agent_session_journal_unreadable', - { reason: 'journalOwnedElsewhere', processKind }, - JOURNAL_OWNER_REFUSAL_MESSAGES[processKind] - ) -} - -/** Why structured chats are refused in this process, or null when nothing refuses them. */ -export function structuredAgentSessionHostRefusal(): AgentSessionRefusalError | null { - return structuredAgentSessionJournalOwnerRefusal() ?? installRefusal -} - -/** Whether `error` is the refusal structured requests are getting right now. */ -export function isStructuredAgentSessionHostRefusal(error: unknown): boolean { - const refusal = structuredAgentSessionHostRefusal() - return ( - refusal !== null && - isAgentSessionRefusalError(error) && - error.refusal.code === refusal.refusal.code && - error.refusal.message === refusal.refusal.message - ) -} - -/** For work that goes on without chats: the refusal chats are getting leaves this process with no - * host, and any other install failure still throws. */ -export async function ensureStructuredAgentSessionHostUnlessRefused( - ensureHost: () => Promise -): Promise { - try { - await ensureHost() - } catch (error) { - if (!isStructuredAgentSessionHostRefusal(error)) { - throw error - } - } -} - -/** Set when the owner could not open its journal database; cleared by a later install. The - * install retries on the next call, so a refusal that can clear does. */ -export function recordStructuredAgentSessionHostInstallRefusal( - refusal: AgentSessionRefusalError | null -): void { - installRefusal = refusal -} - -/** Told when a refused claim is finally granted, so discovery can publish this process. */ -export function onStructuredAgentSessionJournalOwned(listener: () => void): () => void { - ownedListeners.add(listener) - return () => ownedListeners.delete(listener) -} - -/** First, when stopping: a takeover landing during the teardown would install a host after it. - * The refusal stands, so a request in that window is still refused rather than claiming. */ -export function stopRetryingStructuredAgentSessionJournalClaim(): void { - refused?.retry.cancel() -} - -/** Last, after the journal database is closed. Also stops a refused claim's retry. */ -export function releaseStructuredAgentSessionJournal(): void { - stopRetryingStructuredAgentSessionJournalClaim() - refused = null - const lock = held - held = null - lock?.release() -} - -function adopt(lock: JournalOwnerLock): void { - refused?.retry.cancel() - refused = null - held = lock - for (const listener of ownedListeners) { - try { - listener() - } catch (error) { - console.warn('[structured-agent-session] announcing chat journal ownership failed', error) - } - } -} diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index b9e23ff16758..83692ed7b145 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -7,9 +7,8 @@ // reads is module-level for the same reason the registry is — the runtime // service is already far past its size budget. // -// Only the process holding the profile's journal owner lock installs a host. Any -// other process, and an owner whose journal will not open, installs none and -// answers every structured request with the refusal that says why. +// A process whose journal will not open installs none and answers every +// structured request with the refusal that says why. import type { PermissionMode } from '@anthropic-ai/claude-agent-sdk' import { existsSync } from 'node:fs' @@ -41,11 +40,7 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' -import { - releaseStructuredAgentSessionJournal, - stopRetryingStructuredAgentSessionJournalClaim -} from './structured-agent-session-journal-ownership' -import { openOwnedJournalDatabase } from './structured-agent-session-journal-open' +import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { agentSessionStorePath } from './agent-session-record-store-file' import { createStructuredAgentSessionOwnerProbe, @@ -159,38 +154,30 @@ export async function waitForStructuredAgentSessionRecovery(): Promise { * * A teardown that fails is RETRIED by the next stop rather than forgotten: the * host keeps every conversation it could not settle, and this is the only handle - * onto that host once the module slot is cleared. The owner lock goes last, and - * only once nothing is left to retry. */ + * onto that host once the module slot is cleared. */ export async function stopStructuredAgentSessionRuntime(options?: { trigger?: AgentSessionResumeTrigger }): Promise { - stopRetryingStructuredAgentSessionJournalClaim() const trigger = options?.trigger ?? structuredAgentSessionTeardownTrigger() - const failures: unknown[] = [] - // An install that began while this stop awaited (a takeover already under way) is torn down - // too: no journal connection may outlive the lock. + const pending = installing + installing = null + setStructuredAgentSessionHost(null) const outstanding = [...pendingTeardown] pendingTeardown.clear() - do { - const pending = installing - installing = null - setStructuredAgentSessionHost(null) - const installed = await pending?.catch(() => null) - for (const runtime of [...outstanding.splice(0), ...(installed ? [installed] : [])]) { - try { - await tearDownRuntime(runtime, trigger) - } catch (error) { - pendingTeardown.add(runtime) - failures.push(error) - } + const installed = pending ? await pending.catch(() => null) : null + if (installed) { + outstanding.push(installed) + } + const failures: unknown[] = [] + for (const runtime of outstanding) { + try { + await tearDownRuntime(runtime, trigger) + } catch (error) { + pendingTeardown.add(runtime) + failures.push(error) } - await agentModelCatalogStore.flushPersistence() - } while (installing) - // An install a pass awaited registered its host after that pass cleared the slot. - setStructuredAgentSessionHost(null) - if (failures.length === 0 && pendingTeardown.size === 0) { - releaseStructuredAgentSessionJournal() } + await agentModelCatalogStore.flushPersistence() if (failures.length === 1) { throw failures[0] } @@ -206,7 +193,7 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { expect(screen.getByTestId('message-list')).toBeTruthy() expect(screen.getByText("Orca couldn't open this chat's history right now.")).toBeTruthy() }) - -it('says chats are open in another Orca once, with the step for how this one was started', () => { - mocks.status = 'error' - mocks.readRefusal = { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } - } - mocks.messages = [] - - renderPane() - - expect( - screen.getAllByText( - "Chats are open in another Orca using this profile. This chat's history couldn't be loaded. Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH)." - ) - ).toHaveLength(1) - expect(screen.queryByText(/agent_session_/)).toBeNull() -}) diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index c202fcd49522..812197d6e336 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -109,20 +109,6 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.chatNotFound', COPY.chatNotFound), startNewChat: () => translate('components.native-chat.writeNotice.startNewChat', COPY.startNewChat), - chatsOpenElsewhere: () => - translate('components.native-chat.writeNotice.chatsOpenElsewhere', COPY.chatsOpenElsewhere), - quitOtherOrca: () => - translate('components.native-chat.writeNotice.quitOtherOrca', COPY.quitOtherOrca), - quitOtherOrcaOrUseDevProfile: () => - translate( - 'components.native-chat.writeNotice.quitOtherOrcaOrUseDevProfile', - COPY.quitOtherOrcaOrUseDevProfile - ), - stopOtherOrcaOrUseDataFolder: () => - translate( - 'components.native-chat.writeNotice.stopOtherOrcaOrUseDataFolder', - COPY.stopOtherOrcaOrUseDataFolder - ), tryAgain: () => translate('components.native-chat.writeNotice.tryAgain', COPY.tryAgain) } diff --git a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts index c1278dc65170..eaee915ac685 100644 --- a/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts +++ b/src/renderer/src/components/native-chat/structured-agent-session-read-transport.test.ts @@ -400,23 +400,23 @@ describe('structured agent-session read transport unattached refusals', () => { data: { refusal: { code: 'agent_session_journal_unreadable', details } } } } - const OWNED_BY_DEV_ORCA = { + const JOURNAL_CORRUPT = { code: 'agent_session_journal_unreadable', - details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } + details: { reason: 'journalCorrupt' } } - it("hands the pane the owner refusal's process kind from the stream and a rejected read", async () => { + it("hands the pane a thrown refusal's reason from the stream and a rejected read", async () => { vi.useFakeTimers() try { const applyError = vi.fn() const streamed = startWithHydration(async () => undefined, applyError) await flushPromises() - attempts[0].onError(thrownRefusal(OWNED_BY_DEV_ORCA.details)) + attempts[0].onError(thrownRefusal(JOURNAL_CORRUPT.details)) attempts[0].closed.resolve({ unsubscribe: attempts[0].unsubscribe }) await flushPromises() expect(applyError).toHaveBeenCalledExactlyOnceWith( 'agent_session_journal_unreadable', - OWNED_BY_DEV_ORCA + JOURNAL_CORRUPT ) streamed.dispose() @@ -424,7 +424,7 @@ describe('structured agent-session read transport unattached refusals', () => { const refused = new RuntimeRpcCallError({ id: 'req-1', ok: false, - error: thrownRefusal(OWNED_BY_DEV_ORCA.details) + error: thrownRefusal(JOURNAL_CORRUPT.details) }) const rejected = startWithHydration(async () => { throw refused @@ -432,7 +432,7 @@ describe('structured agent-session read transport unattached refusals', () => { await flushPromises() expect(rejectedError).toHaveBeenCalledExactlyOnceWith( 'agent_session_journal_unreadable', - OWNED_BY_DEV_ORCA + JOURNAL_CORRUPT ) rejected.dispose() } finally { diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index bec87fc9fa80..4f4b40fb9057 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -470,7 +470,7 @@ describe('a send the host refused by throwing', () => { data: { refusal: { code: 'agent_session_journal_unreadable', - details: { reason: 'journalOwnedElsewhere', processKind: 'packaged' } + details: { reason: 'journalCorrupt' } } } } @@ -491,11 +491,11 @@ describe('a send the host refused by throwing', () => { expect(result.current.outbox[0]?.lastFailure).toEqual({ kind: 'refused', code: 'agent_session_journal_unreadable', - details: { reason: 'journalOwnedElsewhere', processKind: 'packaged' } + details: { reason: 'journalCorrupt' } }) ) expect(shownFailure(result.current.outbox[0])).toBe( - 'Chats are open in another Orca using this profile. Your message was not sent. Quit that Orca to use chats here.' + 'Unable to load this chat. Your message was not sent.' ) }) }) diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 58372d1ed3a7..90fc6192a119 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2768,10 +2768,6 @@ "restartFailed": "The agent couldn't restart.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", "startNewChat": "Start a new chat to continue.", - "chatsOpenElsewhere": "Chats are open in another Orca using this profile.", - "quitOtherOrca": "Quit that Orca to use chats here.", - "quitOtherOrcaOrUseDevProfile": "Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).", - "stopOtherOrcaOrUseDataFolder": "Stop that Orca to use chats here, or give this one its own data folder (ORCA_USER_DATA).", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", "tryAgain": "Try again.", "tryAgainComposerSend": "Send it again.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 1a862eb943b4..9351961c28ab 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17527,10 +17527,6 @@ "recordUnreadable": "Orca couldn't read this chat's saved state.", "chatNotFound": "The Orca running this chat has no record of it.", "startNewChat": "Start a new chat to continue.", - "chatsOpenElsewhere": "Chats are open in another Orca using this profile.", - "quitOtherOrca": "Quit that Orca to use chats here.", - "quitOtherOrcaOrUseDevProfile": "Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).", - "stopOtherOrcaOrUseDataFolder": "Stop that Orca to use chats here, or give this one its own data folder (ORCA_USER_DATA).", "tryAgain": "Try again." }, "taskList": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index e34f5ee38de1..e0e77e574ec9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14976,10 +14976,6 @@ "recordUnreadable": "Orca no pudo leer el estado guardado de este chat.", "chatNotFound": "La versión de Orca que ejecuta este chat no tiene ningún registro de él.", "startNewChat": "Inicie un chat nuevo para continuar.", - "chatsOpenElsewhere": "Los chats están abiertos en otro Orca que usa este perfil.", - "quitOtherOrca": "Cierre ese Orca para usar los chats aquí.", - "quitOtherOrcaOrUseDevProfile": "Cierre ese Orca para usar los chats aquí, o inicie este con su propio perfil (ORCA_DEV_USER_DATA_PATH).", - "stopOtherOrcaOrUseDataFolder": "Detenga ese Orca para usar los chats aquí, o asigne a este su propia carpeta de datos (ORCA_USER_DATA).", "tryAgain": "Vuelva a intentarlo." }, "composer": { diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index e2e08f5aba7c..9a0f0d437c93 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17459,10 +17459,6 @@ "recordUnreadable": "Orca n'a pas pu lire l'état enregistré de ce chat.", "chatNotFound": "L'instance d'Orca qui exécute ce chat n'en a aucune trace.", "startNewChat": "Démarrez un nouveau chat pour continuer.", - "chatsOpenElsewhere": "Les chats sont ouverts dans un autre Orca qui utilise ce profil.", - "quitOtherOrca": "Quittez cet Orca pour utiliser les chats ici.", - "quitOtherOrcaOrUseDevProfile": "Quittez cet Orca pour utiliser les chats ici, ou démarrez celui-ci avec son propre profil (ORCA_DEV_USER_DATA_PATH).", - "stopOtherOrcaOrUseDataFolder": "Arrêtez cet Orca pour utiliser les chats ici, ou attribuez à celui-ci son propre dossier de données (ORCA_USER_DATA).", "tryAgain": "Réessayez." }, "composer": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a6d61bdbc845..6ec0e9c65e7e 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17395,10 +17395,6 @@ "recordUnreadable": "Orca はこのチャットの保存済みの状態を読み取れませんでした。", "chatNotFound": "このチャットを実行している Orca には、このチャットの記録がありません。", "startNewChat": "続けるには、新しいチャットを開始してください。", - "chatsOpenElsewhere": "このプロファイルを使用している別の Orca でチャットが開かれています。", - "quitOtherOrca": "ここでチャットを使うには、その Orca を終了してください。", - "quitOtherOrcaOrUseDevProfile": "ここでチャットを使うには、その Orca を終了するか、この Orca を専用のプロファイル (ORCA_DEV_USER_DATA_PATH) で起動してください。", - "stopOtherOrcaOrUseDataFolder": "ここでチャットを使うには、その Orca を停止するか、この Orca に専用のデータフォルダー (ORCA_USER_DATA) を指定してください。", "tryAgain": "もう一度お試しください。" }, "composer": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index c79d1a6cf9a4..e5d5cac60fcc 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17395,10 +17395,6 @@ "recordUnreadable": "Orca가 이 채팅의 저장된 상태를 읽지 못했습니다.", "chatNotFound": "이 채팅을 실행하는 Orca에 이 채팅의 기록이 없습니다.", "startNewChat": "계속하려면 새 채팅을 시작하세요.", - "chatsOpenElsewhere": "이 프로필을 사용하는 다른 Orca에서 채팅이 열려 있습니다.", - "quitOtherOrca": "여기에서 채팅을 사용하려면 해당 Orca를 종료하세요.", - "quitOtherOrcaOrUseDevProfile": "여기에서 채팅을 사용하려면 해당 Orca를 종료하거나, 이 Orca를 별도의 프로필(ORCA_DEV_USER_DATA_PATH)로 시작하세요.", - "stopOtherOrcaOrUseDataFolder": "여기에서 채팅을 사용하려면 해당 Orca를 중지하거나, 이 Orca에 별도의 데이터 폴더(ORCA_USER_DATA)를 지정하세요.", "tryAgain": "다시 시도하세요." }, "composer": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index e87bd1f0d86d..d8a7ebf4561f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17360,10 +17360,6 @@ "recordUnreadable": "Orca 无法读取此聊天的已保存状态。", "chatNotFound": "运行此聊天的 Orca 没有此聊天的记录。", "startNewChat": "请开始新的聊天以继续。", - "chatsOpenElsewhere": "聊天已在使用此配置文件的另一个 Orca 中打开。", - "quitOtherOrca": "请退出该 Orca,以便在此处使用聊天。", - "quitOtherOrcaOrUseDevProfile": "请退出该 Orca 以在此处使用聊天,或使用独立的配置文件(ORCA_DEV_USER_DATA_PATH)启动此 Orca。", - "stopOtherOrcaOrUseDataFolder": "请停止该 Orca 以在此处使用聊天,或为此 Orca 指定独立的数据文件夹(ORCA_USER_DATA)。", "tryAgain": "请重试。" }, "composer": { diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 8950b3bac6b1..6080a772ee2a 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -95,9 +95,7 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** SQLite reports the chat's journal damaged or not a database; no retry reads past it. */ 'journalCorrupt', /** Any other failed open, which can clear. */ - 'journalUnavailable', - /** Another Orca process on this profile owns the chats; it clears when that one quits. */ - 'journalOwnedElsewhere' + 'journalUnavailable' ], structured_agent_session_unsupported: [ 'clientCapabilityMissing', @@ -128,15 +126,6 @@ type PromptFacts = { type NoFacts = Record -/** How the process refused a profile's chats was started, which decides its way past the owner. */ -export type AgentSessionJournalProcessKind = 'dev-desktop' | 'packaged' | 'orcad' - -const JOURNAL_PROCESS_KINDS: readonly AgentSessionJournalProcessKind[] = [ - 'dev-desktop', - 'packaged', - 'orcad' -] - /** The facts a code carries beside its reason. */ type AgentSessionRefusalFactsByCode = { agent_session_operation_invalid: RewindFacts @@ -154,10 +143,7 @@ type AgentSessionRefusalFactsByCode = { agent_session_operation_conflict: NoFacts agent_session_operation_expired: NoFacts agent_session_operation_capacity: NoFacts - agent_session_journal_unreadable: { - /** Sent with `journalOwnedElsewhere`: the refused process, not the owner. */ - processKind?: AgentSessionJournalProcessKind - } + agent_session_journal_unreadable: NoFacts structured_agent_session_unsupported: NoFacts agent_session_owner_restart_failed: NoFacts } @@ -194,20 +180,14 @@ export type AgentSessionLegacyRefusalFields = { rewindReason?: AgentSessionRewindReason } -/** Every fact a reader keeps: the legacy fields, and the ones only details carry. */ -type AgentSessionRefusalFactFields = AgentSessionLegacyRefusalFields & { - processKind?: AgentSessionJournalProcessKind -} - const FACT_KEYS_BY_CODE: Partial< - Record + Record > = { agent_session_operation_invalid: ['rewindReason'], agent_session_operation_unknown: ['rewindReason'], agent_session_checkpoint_stale: ['currentFence'], agent_session_item_revision_stale: ['currentRevision', 'resolution'], - agent_session_already_resolved: ['currentRevision', 'resolution'], - agent_session_journal_unreadable: ['processKind'] + agent_session_already_resolved: ['currentRevision', 'resolution'] } const OWNER_VERDICTS: readonly AgentSessionOwnerVerdict[] = ['live', 'unverifiable', 'exited'] @@ -217,9 +197,9 @@ function isRecord(value: unknown): value is Record { } function readFact( - key: keyof AgentSessionRefusalFactFields, + key: keyof AgentSessionLegacyRefusalFields, value: unknown -): AgentSessionRefusalFactFields { +): AgentSessionLegacyRefusalFields { switch (key) { case 'currentFence': return Number.isSafeInteger(value) && typeof value === 'number' ? { currentFence: value } : {} @@ -237,10 +217,6 @@ function readFact( const rewindReason = AGENT_SESSION_REWIND_REASONS.find((reason) => reason === value) return rewindReason ? { rewindReason } : {} } - case 'processKind': { - const processKind = JOURNAL_PROCESS_KINDS.find((kind) => kind === value) - return processKind ? { processKind } : {} - } } } @@ -265,7 +241,7 @@ export function readAgentSessionRefusalDetails( + const facts = keys.reduce( (kept, key) => ({ ...kept, ...readFact(key, value[key]) }), {} ) diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index 7019ba609054..a83a95c43880 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -422,11 +422,8 @@ describe('the notice for every reason a host names', () => { const notDone = parts.filter((part) => typeof part === 'string' && part.startsWith('notDone')) const answeredAway = write === 'answer' && parts.includes('questionChanged') const unsupported = failure.code === 'structured_agent_session_unsupported' - // Every journal cause says the history did not load, except one naming another Orca. const saysNotDone = - write === 'read-history' && - failure.code === 'agent_session_journal_unreadable' && - failure.details?.reason !== 'journalOwnedElsewhere' + write === 'read-history' && failure.code === 'agent_session_journal_unreadable' expect(notDone, cell).toEqual( answeredAway || unsupported || saysNotDone ? [] : [NOT_DONE[write]] ) @@ -542,30 +539,6 @@ describe('a chat whose history the host could not open', () => { expect(notice({ reason }, write)).toBe(expected) }) - // The step is this process's own way past the owner, by how it was started. A kind this build - // does not know, or none from a host that sent it, reads as the packaged app's. - it.each([ - [ - 'dev-desktop', - 'Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).' - ], - ['packaged', 'Quit that Orca to use chats here.'], - [ - 'orcad', - 'Stop that Orca to use chats here, or give this one its own data folder (ORCA_USER_DATA).' - ], - [undefined, 'Quit that Orca to use chats here.'], - ['a-kind-from-a-newer-host', 'Quit that Orca to use chats here.'] - ])('says chats are open in another Orca, for process kind %s', (processKind, step) => { - const details = { reason: 'journalOwnedElsewhere', processKind } - expect(notice(details, 'read-history')).toBe( - `Chats are open in another Orca using this profile. This chat's history couldn't be loaded. ${step}` - ) - expect(notice(details, 'send')).toBe( - `Chats are open in another Orca using this profile. Your message was not sent. ${step}` - ) - }) - // Damage can't be told apart from an open that can clear, so it never says to start over. it.each([ ['an older host', undefined], diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 23df2ad88217..0f4121dc9b31 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -18,7 +18,6 @@ import { import type { AgentSessionRefusalReason } from './agent-session-refusal-details' import { AGENT_SESSION_WRITE_NOTICE_COPY, - JOURNAL_OWNED_ELSEWHERE_STEP, type AgentSessionWriteNoticePart, type AgentSessionWriteNoticeSentence } from './agent-session-write-notice-copy' @@ -201,9 +200,7 @@ const REASON_WORDS = { // No retry reads past damage, and the words name no step: it only can't load. journalCorrupt: causeWords('historyUnusable', 'hostFinding'), // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), - // The step is the packaged app's; `processKind` picks another way past the owner. - journalOwnedElsewhere: causeWords('chatsOpenElsewhere', 'actFirst', 'quitOtherOrca') + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') }, // Thrown, so a client meets these only as an RPC error; the code's words stand. structured_agent_session_unsupported: { @@ -226,12 +223,7 @@ export function agentSessionRefusalReasonWords( const reason = failure.details?.reason const byReason: Partial> | undefined = REASON_WORDS[failure.code] - const words = reason === undefined ? undefined : byReason?.[reason] - // Only the owner refusal records a process kind, which picks its step past the owner. - const kind = failure.code === 'agent_session_journal_unreadable' && failure.details?.processKind - return kind && words && 'cause' in words - ? { ...words, step: JOURNAL_OWNED_ELSEWHERE_STEP[kind] } - : words + return reason === undefined ? undefined : byReason?.[reason] } // Each already says the history was not read. diff --git a/src/shared/agent-session-write-failure.test.ts b/src/shared/agent-session-write-failure.test.ts index 990a5db4b0f0..7fd8b0be3f3c 100644 --- a/src/shared/agent-session-write-failure.test.ts +++ b/src/shared/agent-session-write-failure.test.ts @@ -165,24 +165,24 @@ describe('a refusal a failed request carried in its error', () => { }) }) - it("keeps the owner refusal's reason and process kind", () => { - const owner = { + it("keeps a journal refusal's reason", () => { + const corrupt = { code: 'agent_session_journal_unreadable', - details: { reason: 'journalOwnedElsewhere', processKind: 'dev-desktop' } + details: { reason: 'journalCorrupt' } } // Not "Orca couldn't confirm what happened": the host refused it before running it. expect( - agentSessionThrownFailure(saved({ ...payload, data: { refusal: owner } }), 'runtime_error') + agentSessionThrownFailure(saved({ ...payload, data: { refusal: corrupt } }), 'runtime_error') ).toEqual({ kind: 'refused', - ...owner + ...corrupt }) }) - it("degrades a reason or kind another build added to the code's own words", () => { + it("degrades a reason another build added to the code's own words", () => { const newer = { code: 'agent_session_journal_unreadable', - details: { reason: 'journalFromTheFuture', processKind: 'phone' } + details: { reason: 'journalFromTheFuture' } } expect( agentSessionThrownFailure({ ...payload, data: { refusal: newer } }, 'runtime_error') diff --git a/src/shared/agent-session-write-failure.ts b/src/shared/agent-session-write-failure.ts index fbe850a2dd65..81809133871e 100644 --- a/src/shared/agent-session-write-failure.ts +++ b/src/shared/agent-session-write-failure.ts @@ -4,7 +4,6 @@ import { readAgentSessionRefusalDetails, - type AgentSessionJournalProcessKind, type AgentSessionRefusalReason } from './agent-session-refusal-details' import type { AgentSessionRewindReason } from './agent-session-rewind' @@ -66,15 +65,12 @@ type DurableRefusalFacts = { agent_session_operation_unknown: { rewindReason?: AgentSessionRewindReason } /** A snapshot from when it was refused; see `agentSessionOwnerVerdictAllowsFreshOperationId`. */ agent_session_ownership_unknown: { ownerVerdict?: AgentSessionOwnerVerdict } - /** How the refused process was started, which picks its step past the owner. */ - agent_session_journal_unreadable: { processKind?: AgentSessionJournalProcessKind } } const DURABLE_FACT_KEYS: Partial> = { agent_session_operation_invalid: ['rewindReason'], agent_session_operation_unknown: ['rewindReason'], - agent_session_ownership_unknown: ['ownerVerdict'], - agent_session_journal_unreadable: ['processKind'] + agent_session_ownership_unknown: ['ownerVerdict'] } satisfies { [C in keyof DurableRefusalFacts]: readonly (keyof DurableRefusalFacts[C])[] } export type AgentSessionWriteRefusalDetails = { diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index baf2bc0b55b2..d0c36e27a407 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -1,6 +1,5 @@ // The sentences a chat notice is made of, each whole so desktop can translate it on its own. -import type { AgentSessionJournalProcessKind } from './agent-session-refusal-details' import { QUIT_TERMINAL_AGENT, START_NEW_CHAT, @@ -56,12 +55,6 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { waitMoment: 'Wait a moment.', recordUnreadable: "Orca couldn't read this chat's saved state.", chatNotFound: 'The Orca running this chat has no record of it.', - chatsOpenElsewhere: 'Chats are open in another Orca using this profile.', - quitOtherOrca: 'Quit that Orca to use chats here.', - quitOtherOrcaOrUseDevProfile: - 'Quit that Orca to use chats here, or start this one with its own profile (ORCA_DEV_USER_DATA_PATH).', - stopOtherOrcaOrUseDataFolder: - 'Stop that Orca to use chats here, or give this one its own data folder (ORCA_USER_DATA).', startNewChat: START_NEW_CHAT, tryAgain: 'Try again.' } as const @@ -69,13 +62,3 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { export type AgentSessionWriteNoticeSentence = keyof typeof AGENT_SESSION_WRITE_NOTICE_COPY /** A notice as whole sentences, each translated on its own; `text` is a provider's own words. */ export type AgentSessionWriteNoticePart = AgentSessionWriteNoticeSentence | { text: string } - -/** A process refused its profile's chats gets past the owner by how it was started. */ -export const JOURNAL_OWNED_ELSEWHERE_STEP: Record< - AgentSessionJournalProcessKind, - AgentSessionWriteNoticeSentence -> = { - 'dev-desktop': 'quitOtherOrcaOrUseDevProfile', - packaged: 'quitOtherOrca', - orcad: 'stopOtherOrcaOrUseDataFolder' -} From 3a35f85dd5926c9fc164e3b331b41663047a37f7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:36:19 -0700 Subject: [PATCH 55/66] fix(startup): dev desktops take the single-instance lock, and a second one says why it quit Dev skipped Electron's single-instance lock so parallel `pnpm dev` runs from several worktrees would not quit silently, but two dev processes on the default orca-dev profile then write the same stores at once. Dev now takes the lock like packaged builds: a second launch on the same profile focuses the first window and exits with code 3, printing one stderr line that names the taken profile and how to run another copy (ORCA_DEV_USER_DATA_PATH). Serve mode, the macOS diagnostic bypass and the E2E harness are unchanged: an E2E launch still skips the lock unless it sets ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK=1. --- src/main/startup/main-process-preflight.ts | 10 ++++-- src/main/startup/single-instance-lock.test.ts | 34 +++++++++++++++---- src/main/startup/single-instance-lock.ts | 26 ++++++++++++-- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 100f1e213863..2e143dbb7032 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -224,7 +224,7 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b // look exactly like a cold start, and only the hit/miss/unprovable split tells the two apart. startMainThreadChurnProbe({ extraStats: () => ({ diffCache: settledDiffCache.stats() }) }) // Why: acquire AFTER configureDevUserDataPath — Electron derives lock identity from `userData`, so dev/packaged lock in separate namespaces. - // Why skip in dev: parallel `pnpm dev` from multiple worktrees would make the second exit silently; packaged keeps the lock (corruption PR #1326 / #1312). + // Why dev locks too: two processes on one profile corrupt its stores (PR #1326 / #1312); parallel `pnpm dev` needs ORCA_DEV_USER_DATA_PATH per copy. const bypass = shouldBypassSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) const skip = shouldSkipSingleInstanceLock({ isDev, isServeMode: state.isServeMode }) if (bypass) { @@ -236,12 +236,16 @@ function initializeMainProcessPreflight(options: MainProcessPreflightOptions): b logStartupDiagnostic('single-instance-lock-result', { acquired: hasLock, bypassed: bypass, - skippedForDev: skip + skippedForE2E: skip }) } if (!hasLock) { // Why: a false-negative lock loss otherwise looks like a silent crash on packaged macOS; `open --stderr` can capture this line. - logSingleInstanceLockFailure() + // In dev it is the line `pnpm dev` prints before exiting. + logSingleInstanceLockFailure({ + isDevDesktop: isDev && !state.isServeMode, + userDataPath: app.getPath('userData') + }) // Why: a graceful quit is deferred pre-ready, so this launch would still walk into Linux display init and SIGSEGV (#11935). app.exit(SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE) return false diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index e023d7895f54..942ed8ff1b09 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -9,7 +9,8 @@ import { shouldSkipSingleInstanceLock, SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE, SINGLE_INSTANCE_LOCK_BYPASS_MESSAGE, - SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE + SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, + singleInstanceLockFailureMessage } from './single-instance-lock' type Listener = (...args: unknown[]) => void @@ -117,18 +118,23 @@ describe('SINGLE_INSTANCE_ALREADY_RUNNING_EXIT_CODE', () => { }) describe('shouldSkipSingleInstanceLock', () => { - it('keeps ordinary dev multi-instance behavior but never skips for serve', () => { - expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(true) - expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + const E2E = { ORCA_E2E_USER_DATA_DIR: '/tmp/orca-e2e-userdata-1' } + + it('takes the lock for dev desktop, packaged and serve launches alike', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: {} })).toBe(false) expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: false, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: {} })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: false, isServeMode: true, env: E2E })).toBe(false) + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: true, env: E2E })).toBe(false) }) - it('lets isolated E2E exercise the production single-instance path', () => { + it('skips only an E2E launch on its own profile that did not ask for the lock', () => { + expect(shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, env: E2E })).toBe(true) expect( shouldSkipSingleInstanceLock({ isDev: true, isServeMode: false, - env: { ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } + env: { ...E2E, ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' } }) ).toBe(false) }) @@ -138,11 +144,25 @@ describe('logSingleInstanceLockFailure', () => { it('emits a production-visible synchronous diagnostic for the early quit path', () => { const write = vi.fn() - logSingleInstanceLockFailure(write) + logSingleInstanceLockFailure({ isDevDesktop: false, userDataPath: '/profile' }, write) expect(write).toHaveBeenCalledWith(2, `${SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE}\n`) expect(write.mock.calls[0]?.[1]).toContain('Electron/macOS single-instance lock failure') }) + + it('tells a second dev launch which profile is taken and how to run another copy', () => { + const write = vi.fn() + const userDataPath = '/Users/dev/Library/Application Support/orca-dev' + + logSingleInstanceLockFailure({ isDevDesktop: true, userDataPath }, write) + + expect(write).toHaveBeenCalledOnce() + const line = String(write.mock.calls[0]?.[1]) + expect(line).toBe(`${singleInstanceLockFailureMessage({ isDevDesktop: true, userDataPath })}\n`) + expect(line.trimEnd()).not.toContain('\n') + expect(line).toContain(`the profile at ${userDataPath}`) + expect(line).toContain('ORCA_DEV_USER_DATA_PATH=') + }) }) describe('shouldBypassSingleInstanceLock', () => { diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index 69404b42e806..ff3d4d9f2810 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -64,17 +64,37 @@ export function shouldBypassSingleInstanceLock(options: { ) } +// Why only the E2E harness: dev desktops lock like packaged ones, since two on one profile corrupt +// every store; each E2E launch has its own throwaway profile, and a spec opts in to test the lock. export function shouldSkipSingleInstanceLock(options: { env?: NodeJS.ProcessEnv isDev: boolean isServeMode: boolean }): boolean { const env = options.env ?? process.env - return options.isDev && !options.isServeMode && env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' + return ( + options.isDev && + !options.isServeMode && + Boolean(env.ORCA_E2E_USER_DATA_DIR) && + env[SINGLE_INSTANCE_LOCK_E2E_ENFORCE_ENV] !== '1' + ) +} + +export function singleInstanceLockFailureMessage(options: { + isDevDesktop: boolean + userDataPath: string +}): string { + if (!options.isDevDesktop) { + return SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE + } + return `[single-instance] Another Orca dev instance is already running on the profile at ${options.userDataPath}; exiting this launch after focusing its window. To run another dev copy at the same time, give it its own profile: ORCA_DEV_USER_DATA_PATH= pnpm dev` } -export function logSingleInstanceLockFailure(write?: StartupDiagnosticSink): void { - writeStartupDiagnosticLine(SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE, write) +export function logSingleInstanceLockFailure( + options: { isDevDesktop: boolean; userDataPath: string }, + write?: StartupDiagnosticSink +): void { + writeStartupDiagnosticLine(singleInstanceLockFailureMessage(options), write) } export function logSingleInstanceLockBypass(write?: StartupDiagnosticSink): void { From 94182a8a21b7f1c84f12ad99d19327fe82cc3db9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:53:24 -0700 Subject: [PATCH 56/66] refactor(native-chat): key journal rows by chat, epoch and sequence Rows in the host's journal database are now addressed by the chat's own identity, with `(session_id, epoch, seq)` as the primary key, the same shape each per-chat file already used. The block-keyed layout goes with everything built on it: the block column and its allocator, the 2^21 block ceiling, and the import's reserved block table. A first-use copy writes its rows under the file's epoch, which the chat's pointer does not name until the verified copy publishes it, so no reader sees a half-copied chat. A try that stopped midway leaves only rows no pointer names, and the next try deletes them before it copies again. Replace, rollover and repair delete by (chat, epoch). This build's history always wins: once a chat was copied or founded here, any per-chat file that reappears is set aside, and the same-epoch copy again after a downgrade is removed. The bounded free-page reclaim after every delete is dropped; `auto_vacuum = INCREMENTAL` stays at file creation, so a later periodic reclaim can still be added. Session search keeps its own step. The schema moves to version 3. Versions 1 and 2 were written only by unreleased builds of this change and are refused as found, not migrated. --- .../session-search-retention-delete.ts | 11 +- .../journal-block-replace.test.ts | 86 ---------- .../journal-database-schema.ts | 35 ++-- .../journal-database.test.ts | 156 +++++++---------- .../agent-session-journal/journal-database.ts | 4 + .../journal-epoch-replacement.test.ts | 70 ++++---- .../journal-epoch-replacement.ts | 30 ++-- .../journal-epoch-rollover.ts | 31 ++-- .../journal-host-database-test-support.ts | 60 +++---- .../journal-host-database.test.ts | 39 +---- .../journal-host-database.ts | 36 ---- .../agent-session-journal/journal-open.ts | 27 ++- .../journal-per-session-import.test.ts | 108 ++---------- .../journal-per-session-import.ts | 146 +++++----------- .../journal-per-session-reimport.ts | 72 +------- .../journal-per-session-source.ts | 2 +- .../journal-repair-marker.ts | 19 +-- .../journal-row-table.ts | 158 ++++++------------ .../journal-row-writer.test.ts | 2 - .../journal-row-writer.ts | 3 +- .../journal-session-status.test.ts | 6 +- .../journal-store-collaborators.ts | 2 - .../journal-store-open.ts | 1 - .../journal-store-restore.ts | 2 +- .../agent-session-journal/journal-store.ts | 6 +- ...ession-history-forward-read-budget.test.ts | 2 +- ...ent-session-restore-without-import.test.ts | 8 +- src/main/sqlite/sqlite-free-page-reclaim.ts | 22 --- 28 files changed, 331 insertions(+), 813 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-block-replace.test.ts delete mode 100644 src/main/sqlite/sqlite-free-page-reclaim.ts diff --git a/src/main/ai-vault-search/session-search-retention-delete.ts b/src/main/ai-vault-search/session-search-retention-delete.ts index f64f9484259d..e8d407f8be91 100644 --- a/src/main/ai-vault-search/session-search-retention-delete.ts +++ b/src/main/ai-vault-search/session-search-retention-delete.ts @@ -1,9 +1,13 @@ import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import type SyncDatabase from '../sqlite/sync-database' -import { reclaimFreePagesStep } from '../sqlite/sqlite-free-page-reclaim' import { deleteSearchMessages } from './session-search-message-rows' export const RETENTION_DELETE_ROWS_PER_STEP = 256 +// Why in step with the deletes rather than one sweep at the end: `auto_vacuum = +// INCREMENTAL` holds every freed page until something asks for it back, and +// asking for a whole purge's worth at once is one long stall (40 ms per 22 MB +// freed, measured) instead of many short ones. +const RECLAIM_PAGES_PER_STEP = 2000 /** * Drops every file older than the cutoff, then hands its rows back in bounded @@ -87,13 +91,12 @@ export async function drainOrphanedMessages( db.exec('ROLLBACK') throw error } - // In step with the deletes rather than one sweep at the end: many short stalls, not one long one. - reclaimFreePagesStep(db) + db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`) if (deleted < RETENTION_DELETE_ROWS_PER_STEP) { orphan = (nextOrphan.get() as { session_row_id: number } | undefined)?.session_row_id } await yieldStep() } // A `removeFile` frees its pages outside this loop and may leave none to drain. - reclaimFreePagesStep(db) + db.pragma(`incremental_vacuum(${RECLAIM_PAGES_PER_STEP})`) } diff --git a/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts b/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts deleted file mode 100644 index c00feb258851..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-block-replace.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -// T-block: rewinding one chat among interleaved peers rewrites that chat's rows, not the file's. -// -// Written only against the store, so the same case measures any key layout: under block keys a -// chat's rows are one contiguous range, and a replace frees that range; under a key whose table -// order follows insertion, the chat's rows are spread across every leaf its peers share. - -import { mkdtemp, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { journalDatabasePath } from './journal-host-database' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import type { AgentSessionJournal } from './journal-store' - -const PEERS = 19 -const ROWS_PER_CHAT = 600 -const TEXT = 'x'.repeat(500) - -let root: string -const journals = createTrackedJournalOpener() - -function identity(index: number): AgentSessionJournalIdentity { - return { - sessionId: `session-${index}`, - workspaceId: 'ws-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: `thread-${index}` } - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-block-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -it('replaces one chat among 19 interleaved peers with a write-ahead log a fraction of its size', async () => { - // Seeding only: the case measures the replace, not ten thousand fsyncs before it. - openTestJournalHostDatabase(root).db.pragma('synchronous = OFF') - const chats: AgentSessionJournal[] = [] - for (let index = 0; index <= PEERS; index += 1) { - chats.push(await journals.open({ identity: identity(index), stateDirectory: root })) - } - // Interleaved, as concurrent chats write: every chat's row n lands before any chat's row n + 1. - for (let ordinal = 1; ordinal <= ROWS_PER_CHAT; ordinal += 1) { - for (const [index, chat] of chats.entries()) { - await chat.appendItem( - { provider: 'codex', threadId: `thread-${index}`, turnId: 'turn-1', ordinal }, - { kind: 'status', text: TEXT }, - { fence: 1 } - ) - } - } - const database = openTestJournalHostDatabase(root) - // A free-page pass writes its own WAL frames a turn after a delete; this measures the replace alone. - // The seed never yields a turn, so the pass opening the chats started is still waiting: drain it first. - await database.reclaimFreePages() - vi.spyOn(database, 'reclaimFreePages').mockResolvedValue() - const { db } = database - db.pragma('synchronous = FULL') - db.pragma('wal_autocheckpoint = 0') - db.pragma('wal_checkpoint(TRUNCATE)') - const walPath = `${journalDatabasePath(root)}-wal` - const total = db.prepare('SELECT sum(length(row_json)) AS total FROM journal_rows').get()?.total - const chatBytes = Number(total) / (PEERS + 1) - - await chats[0]!.replaceEpochItems('legacy_import', 1, [ - { - identity: { provider: 'codex', threadId: 'thread-0', turnId: 'turn-9', ordinal: 1 }, - body: { kind: 'status', text: 'rewound' } - } - ]) - - // Measured on this seed: block keys about 0.7x the chat's bytes; a key whose table order follows - // insertion about 6x, the whole file. The G2 rewind row measures the largest real chat. - const walBytes = (await stat(walPath)).size - expect(walBytes).toBeLessThan(chatBytes) -}, 120_000) 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 6c1b40a74e16..a2c31116a5b5 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,32 +1,33 @@ // Table shape for the host's one chat journal database. // -// `journal_rows` is every chat's append-only log, keyed `block * 2^32 + seq` (journal-row-table.ts). -// `journal_sessions` names each chat's live epoch and the block its rows sit in, 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 last 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; `journal_import_blocks` -// the block a copy still in progress writes into, which no reader follows. `status_json` is the chat's last settled -// listing status, trusted only where `status_seq` is still the tip of the live epoch. +// `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. `status_json` is the chat's last settled listing status, trusted only where `status_seq` +// is still the tip of the live epoch. /** 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 latches this build - * out, so every change stays additive. */ -export const JOURNAL_DB_SCHEMA_VERSION = 2 + * out, 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 ` CREATE TABLE IF NOT EXISTS journal_rows ( - id INTEGER PRIMARY KEY, - ts INTEGER NOT NULL, - row_json TEXT NOT NULL + 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 IF NOT EXISTS 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 ); @@ -46,9 +47,5 @@ CREATE TABLE IF NOT EXISTS journal_set_aside ( epoch TEXT NOT NULL, tip INTEGER NOT NULL ); -CREATE TABLE IF NOT EXISTS journal_import_blocks ( - session_id TEXT PRIMARY KEY, - block INTEGER NOT NULL UNIQUE -); ` } 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 2789dbf9a455..7d4df9d9864e 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 @@ -14,20 +14,14 @@ import { import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' import { - allocateJournalBlock, - deleteJournalBlock, + deleteJournalEpochRows, deleteJournalRowSuffix, + deleteUnpublishedJournalRows, insertJournalRow, iterateJournalEpochRows, - JOURNAL_BLOCK_LIMIT, - JOURNAL_SEQUENCE_LIMIT, - JournalKeySpaceError, - journalRowId, publishJournalSessionEpoch, readJournalRowsAfter, - readJournalSessionPointer, - readJournalTip, - type JournalBlockPointer + readJournalSessionEpoch } from './journal-row-table' import type { JournalRow } from './journal-row-schema' import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' @@ -50,8 +44,8 @@ function epochRow(seq: number, epoch = 'epoch-1'): JournalRow { const SESSION = { sessionId: 'session-1', workspaceId: 'ws-1' } -function rowsOf(db: Database.Database, pointer: JournalBlockPointer): number[] { - return [...iterateJournalEpochRows(db, pointer)].map((row) => row.seq) +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 { @@ -98,8 +92,8 @@ describe('the host journal database open', () => { // T5: a newer build's database is refused and left byte-identical. it('refuses a newer user_version without touching the file', async () => { const seeded = openJournalDatabase(dbPath) - publishJournalSessionEpoch(seeded, SESSION, { epoch: 'epoch-1', block: 0 }) - insertJournalRow(seeded, 0, epochRow(1)) + 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) @@ -120,91 +114,80 @@ describe('the host journal database open', () => { }) describe('journal row statements', () => { - it('serves replay, resume, the tip, suffix truncation and a block discard', () => { + it('serves replay, resume, suffix truncation and an epoch discard', () => { const db = openJournalDatabase(dbPath) try { - const pointer = { epoch: 'epoch-1', block: 0 } - const other = { epoch: 'epoch-other', block: 1 } db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { - insertJournalRow(db, pointer.block, epochRow(seq)) + insertJournalRow(db, 'session-1', epochRow(seq)) } - insertJournalRow(db, other.block, epochRow(1, 'epoch-other')) - publishJournalSessionEpoch(db, SESSION, pointer) - publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, other) + 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(readJournalSessionPointer(db, 'session-1')).toEqual(pointer) - expect(readJournalSessionPointer(db, 'absent')).toBeNull() - expect(rowsOf(db, pointer)).toEqual([1, 2, 3, 4, 5]) - expect(readJournalTip(db, pointer.block)).toBe(5) - expect(readJournalRowsAfter(db, pointer, 3).map((row) => row.seq)).toEqual([4, 5]) + expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1') + expect(readJournalSessionEpoch(db, 'absent')).toBeNull() + 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 + ]) - expect(deleteJournalRowSuffix(db, pointer.block, 4)).toBe(2) - expect(rowsOf(db, pointer)).toEqual([1, 2, 3]) + expect(deleteJournalRowSuffix(db, 'session-1', 'epoch-1', 4)).toBe(2) + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([1, 2, 3]) // Another chat in the same file is untouched by this chat's discard. - deleteJournalBlock(db, pointer.block) - expect(rowsOf(db, pointer)).toEqual([]) - expect(rowsOf(db, other)).toEqual([1]) + deleteJournalEpochRows(db, 'session-1', 'epoch-1') + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) + expect(rowsOf(db, 'session-2', 'epoch-1')).toEqual([1]) } finally { db.close() } }) - it('refuses a duplicate sequence inside one block', () => { + it('deletes only the rows no pointer names', () => { const db = openJournalDatabase(dbPath) try { - insertJournalRow(db, 0, epochRow(1)) - expect(() => insertJournalRow(db, 0, epochRow(1))).toThrow() - insertJournalRow(db, 1, epochRow(1)) - } finally { - db.close() - } - }) + 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') - it('moves the epoch pointer in place and forgets the saved status with it', () => { - const db = openJournalDatabase(dbPath) - try { - publishJournalSessionEpoch(db, SESSION, { epoch: 'epoch-1', block: 0 }) - db.exec("UPDATE journal_sessions SET status_json = '{}', status_seq = 3") - publishJournalSessionEpoch(db, SESSION, { epoch: 'epoch-2', block: 1 }) - expect(readJournalSessionPointer(db, 'session-1')).toEqual({ epoch: 'epoch-2', block: 1 }) - expect( - db - .prepare('SELECT count(*) AS total, max(status_json) AS status FROM journal_sessions') - .get() - ).toMatchObject({ total: 1, status: null }) + deleteUnpublishedJournalRows(db, 'session-1') + deleteUnpublishedJournalRows(db, 'session-2') + + expect(rowsOf(db, 'session-1', 'epoch-copying')).toEqual([]) + expect(rowsOf(db, 'session-2', 'epoch-live')).toEqual([1]) + expect(rowsOf(db, 'session-2', 'epoch-stale')).toEqual([]) } finally { db.close() } }) -}) -// T-block: the key's bounds, where Number arithmetic has to stay exact. -describe('block keys', () => { - it('keys the last sequence of the last block exactly, below 2^53', () => { + it('refuses a duplicate sequence inside one epoch of one chat', () => { const db = openJournalDatabase(dbPath) try { - const pointer = { epoch: 'epoch-1', block: JOURNAL_BLOCK_LIMIT - 1 } - const seq = JOURNAL_SEQUENCE_LIMIT - 1 - expect(journalRowId(pointer.block, seq)).toBeLessThanOrEqual(Number.MAX_SAFE_INTEGER) - insertJournalRow(db, pointer.block, epochRow(seq)) - insertJournalRow(db, pointer.block - 1, epochRow(seq)) - expect(readJournalRowsAfter(db, pointer, 0).map((row) => row.seq)).toEqual([seq]) - expect(readJournalTip(db, pointer.block)).toBe(seq) + 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 { db.close() } }) - it('refuses a sequence outside its block, and a block past the last', () => { - expect(() => journalRowId(0, JOURNAL_SEQUENCE_LIMIT)).toThrow(JournalKeySpaceError) - expect(() => journalRowId(0, 0)).toThrow(JournalKeySpaceError) + it('moves the epoch pointer in place and forgets the saved status with it', () => { const db = openJournalDatabase(dbPath) try { - publishJournalSessionEpoch(db, SESSION, { epoch: 'epoch-1', block: JOURNAL_BLOCK_LIMIT - 1 }) - expect(() => allocateJournalBlock(db)).toThrow(JournalKeySpaceError) + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + db.exec("UPDATE journal_sessions SET status_json = '{}', status_seq = 3") + publishJournalSessionEpoch(db, SESSION, 'epoch-2') + expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-2') + expect( + db + .prepare('SELECT count(*) AS total, max(status_json) AS status FROM journal_sessions') + .get() + ).toMatchObject({ total: 1, status: null }) } finally { db.close() } @@ -241,36 +224,23 @@ describe('schema creation', () => { } }) - // A database an earlier head of this schema wrote gains the set-aside table and keeps its rows. - it('upgrades a version 1 database in place', () => { - const v1 = new Database(dbPath) - v1.pragma('auto_vacuum = INCREMENTAL') - v1.pragma('journal_mode = WAL') - v1.exec(` + // 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); -CREATE TABLE journal_repairs (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, - content_from INTEGER NOT NULL, repaired_at INTEGER NOT NULL); -CREATE TABLE journal_imports (session_id TEXT PRIMARY KEY, epoch TEXT NOT NULL, tip INTEGER NOT NULL); -CREATE TABLE journal_import_blocks (session_id TEXT PRIMARY KEY, block INTEGER NOT NULL UNIQUE); -INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL); -INSERT INTO journal_imports VALUES ('s1', 'e0', 3);`) - v1.pragma('user_version = 1') - v1.close() +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) - const db = openJournalDatabase(dbPath) - try { - expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) - expect( - db.prepare("SELECT name FROM sqlite_master WHERE name = 'journal_set_aside'").get() - ).toBeTruthy() - expect(db.prepare('SELECT session_id, epoch, tip FROM journal_imports').all()).toEqual([ - { session_id: 's1', epoch: 'e0', tip: 3 } - ]) - expect(journalPragmaNumber(db, 'auto_vacuum')).toBe(2) - } finally { - db.close() - } + expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) + + 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 387fd5c39da2..1b272b77bfd0 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -39,6 +39,10 @@ export function openJournalDatabase(dbPath: string): Database.Database { if (stored > JOURNAL_DB_SCHEMA_VERSION) { throw new JournalDatabaseNewerSchemaError(stored, dbPath) } + if (stored !== 0 && stored < JOURNAL_DB_SCHEMA_VERSION) { + // Only unreleased development builds wrote these shapes; left as found, never migrated. + throw new Error(`chat journal ${dbPath} uses unreleased schema ${stored}; move it aside`) + } configureJournalPragmas(probe, stored) createJournalSchema(probe, stored) hardenSqliteDatabaseFiles(dbPath) 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 34cb8908afa8..478d2e69e8c0 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 @@ -1,16 +1,17 @@ // Republishing an epoch is ONE transaction. -import { mkdtemp, rm, stat } from 'node:fs/promises' +import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { journalDatabasePath, type JournalHostDatabase } from './journal-host-database' +import type { JournalHostDatabase } from './journal-host-database' import { replaceJournalEpoch } from './journal-epoch-replacement' import type { JournalLoad } from './journal-open' +import type { AgentSessionJournal } from './journal-store' import { readJournalSessionEpoch } from './journal-row-table' import { createTrackedJournalOpener, @@ -26,6 +27,12 @@ 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: JournalHostDatabase @@ -36,6 +43,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 } } @@ -87,43 +103,29 @@ describe('journal epoch replacement', () => { expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2) }) - it('discards every superseded row in the same transaction', async () => { + // 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 }) await journal.appendItem(item(1), { kind: 'status', text: 'old' }, { fence: 1 }) await journal.appendItem(item(2), { kind: 'status', text: 'older' }, { fence: 1 }) + await peer.appendItem(item(1), { kind: 'status', text: 'peer' }, { fence: 1 }) 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(readTestJournalRows(database.db, IDENTITY.sessionId, before)).toHaveLength(0) - expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([ - { kind: 'status', text: 'republished' } - ]) - }) - - // `auto_vacuum = INCREMENTAL` only marks freed pages; nothing hands them back unless asked. A - // rewind that replaces a large chat must not leave the file holding its old history forever. - it('hands the pages a large replace freed back to the filesystem', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const text = 'x'.repeat(4_000) - for (let ordinal = 1; ordinal <= 400; ordinal += 1) { - await journal.appendItem(item(ordinal), { kind: 'status', text }, { fence: 1 }) - } - database.db.pragma('wal_checkpoint(TRUNCATE)') - const grown = (await stat(journalDatabasePath(root))).size - - await journal.replaceEpochItems('legacy_import', 1, [ - { identity: item(9), body: { kind: 'status', text: 'republished' } } - ]) - // The replace schedules the reclaim itself; this only waits for it. - await vi.waitFor( - () => expect(Number(database.db.pragma('freelist_count', { simple: true }))).toBeLessThan(8), - { timeout: 2_000 } - ) - database.db.pragma('wal_checkpoint(TRUNCATE)') - expect((await stat(journalDatabasePath(root))).size).toBeLessThan(grown / 4) + 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 7e231d2e7c33..6237d1248265 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,10 +1,8 @@ // Republishing a live item set into a fresh epoch. // -// One transaction: discard the old block, insert the epoch row plus the -// replacement items into a fresh one, move the session projection, and retire -// any repair marker — this republished history is exactly what the marker was -// holding out for. The pages the old rows held go back to the filesystem after -// the commit. +// 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, @@ -17,11 +15,10 @@ import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { buildJournalItemRow, journalRowBase } from './journal-row-builders' import { - allocateJournalBlock, - deleteJournalBlock, + deleteJournalEpochRows, insertJournalRow, publishJournalSessionEpoch, - readJournalSessionPointer + readJournalSessionEpoch } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' import { assertJournalFence } from './journal-write-guards' @@ -68,24 +65,21 @@ export function replaceJournalEpoch(input: { } const { sessionId } = input.identity - const block = input.database.transaction((db) => { - const retired = readJournalSessionPointer(db, sessionId) - const fresh = allocateJournalBlock(db) - if (retired) { - deleteJournalBlock(db, retired.block) + 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(db, fresh, row) + insertJournalRow(db, sessionId, row) } - publishJournalSessionEpoch(db, input.identity, { epoch, block: fresh }) - return fresh + 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 // live store writing into an epoch whose rows were just deleted. state.oldestSequence = 1 - input.onPublished({ state, block, readOnly: false, corrupt: false, malformedRows: 0 }) - void input.database.reclaimFreePages() + input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) } 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 9544b2e415fb..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 @@ -1,10 +1,9 @@ // Opening a new epoch. // -// One transaction: discard the superseded epoch's block, insert the new epoch -// row at sequence 1 of a fresh block, move the session projection onto it, and -// retire any repair marker the superseded epoch was carrying. Superseded rows -// are DELETED rather than retained — nothing would ever shed them — and the -// pages they held go back to the filesystem after the commit. +// One transaction: discard every row of the superseded epoch, insert the new +// epoch row at sequence 1, move the session projection onto it, and retire any +// repair marker the superseded epoch was carrying. Superseded rows are DELETED +// rather than retained — nothing would ever shed them. import { journalRowSchemaVersion } from '../../../shared/agent-session-journal-types' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' @@ -13,11 +12,10 @@ import type { JournalLoad } from './journal-open' import { clearJournalRepairMarker } from './journal-repair-marker' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { - allocateJournalBlock, - deleteJournalBlock, + deleteJournalEpochRows, insertJournalRow, publishJournalSessionEpoch, - readJournalSessionPointer + readJournalSessionEpoch } from './journal-row-table' import type { AgentJournalEpochReason, JournalRow } from './journal-row-schema' @@ -44,16 +42,14 @@ export function publishNewEpoch(input: { } const { sessionId } = input.identity - const block = input.database.transaction((db) => { - const retired = readJournalSessionPointer(db, sessionId) - const fresh = allocateJournalBlock(db) - if (retired) { - deleteJournalBlock(db, retired.block) + input.database.transaction((db) => { + const retired = readJournalSessionEpoch(db, sessionId) + if (retired !== null) { + deleteJournalEpochRows(db, sessionId, retired) } clearJournalRepairMarker(db, sessionId) - insertJournalRow(db, fresh, row) - publishJournalSessionEpoch(db, input.identity, { epoch: input.epoch, block: fresh }) - return fresh + insertJournalRow(db, sessionId, row) + publishJournalSessionEpoch(db, input.identity, input.epoch) }) // COMMIT landed: on disk the superseded prefix is gone and this epoch is the @@ -62,6 +58,5 @@ export function publishNewEpoch(input: { const state = createJournalReducerState(sessionId, input.epoch) applyJournalRow(state, row) state.oldestSequence = 1 - input.onPublished({ state, block, readOnly: false, corrupt: false, malformedRows: 0 }) - void input.database.reclaimFreePages() + input.onPublished({ state, readOnly: false, corrupt: false, malformedRows: 0 }) } diff --git a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts index 0b89b2cfe7d0..9ce0e0c34319 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database-test-support.ts @@ -5,15 +5,11 @@ import { resolve } from 'node:path' import { JournalHostDatabase } from './journal-host-database' import { replayJournal, type JournalLoad } from './journal-open' -import type { JournalRow } from './journal-row-schema' +import { serializeJournalRow, type JournalRow } from './journal-row-schema' import { - allocateJournalBlock, - insertJournalRow, iterateJournalEpochRows, - journalRowId, publishJournalSessionEpoch, - readJournalSessionPointer, - type JournalBlockPointer, + readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' import type Database from '../../sqlite/sync-database' @@ -83,25 +79,21 @@ export function loadTestJournal(stateDirectory: string, sessionId: string): Jour // Staging on-disk states for a case, addressed the way the case thinks of them: by chat and sequence. -function livePointer(db: Database.Database, sessionId: string): JournalBlockPointer { - const pointer = readJournalSessionPointer(db, sessionId) - if (!pointer) { +function liveEpoch(db: Database.Database, sessionId: string): string { + const epoch = readJournalSessionEpoch(db, sessionId) + if (epoch === null) { throw new Error(`no journal for ${sessionId}`) } - return pointer + return epoch } -/** Points the chat at a new epoch in a fresh block, as a publish would. */ +/** 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, block: allocateJournalBlock(db) } - ) + publishJournalSessionEpoch(db, { sessionId, workspaceId: 'ws-1' }, epoch) } /** The chat's rows of `epoch` — none once that epoch is no longer the live one. */ @@ -110,22 +102,24 @@ export function readTestJournalRows( sessionId: string, epoch: string ): JournalStoredRow[] { - const pointer = readJournalSessionPointer(db, sessionId) - return pointer?.epoch === epoch ? [...iterateJournalEpochRows(db, pointer)] : [] + 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 pointer = readJournalSessionPointer(db, sessionId) - return pointer ? [...iterateJournalEpochRows(db, pointer)] : [] + 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 { - insertJournalRow(db, livePointer(db, sessionId).block, row) + 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. */ @@ -133,13 +127,12 @@ export function insertTestJournalRowJson( db: Database.Database, sessionId: string, seq: number, - rowJson: string + rowJson: string, + ts = 1 ): void { - db.prepare('INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)').run( - journalRowId(livePointer(db, sessionId).block, seq), - 1, - rowJson - ) + 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( @@ -148,14 +141,15 @@ export function updateTestJournalRowJson( seq: number, rowJson: string ): void { - db.prepare('UPDATE journal_rows SET row_json = ? WHERE id = ?').run( - rowJson, - journalRowId(livePointer(db, sessionId).block, seq) - ) + 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 id = ?').run( - journalRowId(livePointer(db, sessionId).block, seq) + 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 index ea786a1db012..b0488fae4c5d 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts @@ -18,7 +18,6 @@ import { openTestJournalHostDatabase, readTestJournalRows } from './journal-host-database-test-support' -import { deleteJournalBlock, journalRowId } from './journal-row-table' import type Database from '../../sqlite/sync-database' const IDENTITY: AgentSessionJournalIdentity = { @@ -174,8 +173,10 @@ setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, expect(() => database.unsyncedTransaction((db) => db - .prepare('INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)') - .run(journalRowId(99, 1), 1, '{}') + .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) @@ -233,35 +234,3 @@ describe('quit', () => { ).toEqual([1, 2]) }) }) - -describe('handing freed pages back', () => { - // About one page per row; each block frees more than one reclaim step's worth. - function fillBlock( - db: ReturnType['db'], - block: number - ): void { - const insert = db.prepare('INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)') - db.exec('BEGIN') - for (let seq = 1; seq <= 3500; seq += 1) { - insert.run(journalRowId(block, seq), 1, 'x'.repeat(3500)) - } - db.exec('COMMIT') - } - - it('reclaims what a delete frees while a pass is already running', async () => { - const database = openTestJournalHostDatabase(root) - const freePages = () => Number(database.db.pragma('freelist_count', { simple: true })) - fillBlock(database.db, 0) - fillBlock(database.db, 1) - deleteJournalBlock(database.db, 0) - const pass = database.reclaimFreePages() - // The pass takes its first step, then a second chat's delete lands before its next one. - await new Promise((resolve) => setImmediate(resolve)) - expect(freePages()).toBeGreaterThan(0) - deleteJournalBlock(database.db, 1) - - expect(database.reclaimFreePages()).toBe(pass) - await pass - expect(freePages()).toBe(0) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index 094ec33ab1fa..34c52132973d 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -5,11 +5,9 @@ // open, close, retry or leak, and the connection closes exactly once, last, at host teardown. import { mkdirSync } from 'node:fs' -import { setImmediate as yieldToEventLoop } from 'node:timers/promises' import { join } from 'node:path' import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' -import { freePageCount, reclaimFreePagesStep } from '../../sqlite/sqlite-free-page-reclaim' import { JOURNAL_SYNCHRONOUS, openJournalDatabase, runJournalTransaction } from './journal-database' import { journalOpenRefusalError } from './journal-open-failure' import { journalDirectoryFor } from './journal-paths' @@ -23,7 +21,6 @@ export function journalDatabasePath(stateDirectory: string): string { export class JournalHostDatabase { private connection: Database.Database | null - private reclaiming: Promise | null = null /** A failed transaction's ROLLBACK failed too, so the transaction may still be open. */ private stranded = false @@ -89,16 +86,6 @@ export class JournalHostDatabase { return journalDirectoryFor(this.stateDirectory, identity) } - /** - * Hands the pages a delete freed back to the filesystem, one bounded step per turn of the event - * loop, after the transaction that freed them. Called by everything that deletes rows. Passes - * coalesce: one already running picks up whatever the new delete freed. - */ - reclaimFreePages(): Promise { - this.reclaiming ??= this.runReclaim() - return this.reclaiming - } - /** Last, after every store has drained. A close that fails keeps the handle, so the retried * teardown closes this same connection. */ close(): void { @@ -122,27 +109,4 @@ export class JournalHostDatabase { connection.pragma(`synchronous = ${JOURNAL_SYNCHRONOUS}`) this.stranded = false } - - private async runReclaim(): Promise { - try { - await yieldToEventLoop() - while (!this.isClosed) { - const db = this.db - const before = freePageCount(db) - const remaining = reclaimFreePagesStep(db) - // A step that frees nothing (a file created without incremental auto-vacuum) ends the pass; - // pages a delete freed since the last step do not. - if (remaining === 0 || remaining >= before) { - return - } - await yieldToEventLoop() - } - } catch (error) { - // Bookkeeping: a failed step only leaves pages for the next delete's pass. - console.warn('[agent-session-journal] reclaiming freed pages failed', error) - } finally { - // Cleared as the pass ends, not a microtask later, so a delete right after starts a new one. - this.reclaiming = null - } - } } 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 22dced209165..339b1984024c 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -15,8 +15,7 @@ import { import { iterateJournalEpochRows, readJournalRowsAfter, - readJournalSessionPointer, - type JournalBlockPointer + readJournalSessionEpoch } from './journal-row-table' import { JOURNAL_REPAIR_DISCLOSURE_ITEM_ID } from './journal-repair-disclosure' import { pendingJournalRepairSequence } from './journal-repair-marker' @@ -27,8 +26,6 @@ const FIRST_JOURNAL_SEQUENCE = 1 export type JournalLoad = { state: JournalReducerState - /** The block the live epoch's rows are keyed under. */ - block: number /** 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. */ @@ -43,15 +40,15 @@ export type JournalLoad = { /** 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 pointer = readJournalSessionPointer(db, sessionId) - if (!pointer) { + const epoch = readJournalSessionEpoch(db, sessionId) + if (epoch === null) { return null } return foldJournalRows({ sessionId, - pointer, - repairedFrom: pendingJournalRepairSequence(db, sessionId, pointer.epoch), - rows: iterateJournalEpochRows(db, pointer) + epoch, + repairedFrom: pendingJournalRepairSequence(db, sessionId, epoch), + rows: iterateJournalEpochRows(db, sessionId, epoch) }) } @@ -70,7 +67,7 @@ export function foldJournalRows( export type JournalRowFoldInput = { sessionId: string - pointer: JournalBlockPointer + epoch: string /** The sequence a pending repair on this epoch left free. */ repairedFrom: number | null } @@ -81,8 +78,8 @@ export function startJournalRowFold(input: JournalRowFoldInput): { add: (entry: { seq: number; rowJson: string }) => boolean finish: () => JournalLoad } { - const { pointer, repairedFrom } = input - const state = createJournalReducerState(input.sessionId, pointer.epoch) + const { repairedFrom } = input + const state = createJournalReducerState(input.sessionId, input.epoch) let expectedSequence = FIRST_JOURNAL_SEQUENCE let gapSequence: number | undefined let unanchoredSequence: number | undefined @@ -135,7 +132,6 @@ export function startJournalRowFold(input: JournalRowFoldInput): { state.oldestSequence = FIRST_JOURNAL_SEQUENCE return { state, - block: pointer.block, readOnly: latched, corrupt: gapSequence !== undefined || @@ -154,12 +150,13 @@ export function startJournalRowFold(input: JournalRowFoldInput): { * cannot parse, exactly as replay does. */ export function readJournalRowsAfterCursor( db: Database.Database, - pointer: JournalBlockPointer, + sessionId: string, + epoch: string, afterSequence: number, limit?: number ): JournalRow[] { const rows: JournalRow[] = [] - for (const stored of readJournalRowsAfter(db, pointer, afterSequence, limit)) { + for (const stored of readJournalRowsAfter(db, sessionId, epoch, afterSequence, limit)) { const parsed = parseJournalRow(stored.rowJson) if (!parsed.ok) { break 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 index b5e0f5db2540..399282b4135e 100644 --- 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 @@ -1,7 +1,6 @@ // 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 and carried the copied epoch on is copied again while this build has -// not written past the copy; any other is set aside on disk, and the chat keeps its history. +// reappears after a downgrade is set aside on disk, and the chat keeps this build's history. import type * as NodeFs from 'node:fs' import { existsSync, rmSync } from 'node:fs' @@ -21,13 +20,13 @@ 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 { JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY } from './journal-per-session-reimport' -import { readJournalSessionPointer, type JournalStoredRow } from './journal-row-table' +import { readJournalSessionEpoch, type JournalStoredRow } from './journal-row-table' vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal() @@ -237,7 +236,7 @@ describe('importing a per-chat journal', () => { let ticking = true const tick = (): void => { turns.push({ - published: readJournalSessionPointer(database.db, IDENTITY.sessionId) !== null, + published: readJournalSessionEpoch(database.db, IDENTITY.sessionId) !== null, copied: rowCount(database.db) }) if (ticking) { @@ -260,8 +259,8 @@ describe('importing a per-chat journal', () => { expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch)).toEqual(rows) }) - // A quit between two batches: the next open copies the chat again from the start, with no - // duplicate or leftover row, and no other chat is ever handed the block the copy reserved. + // 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) @@ -277,9 +276,9 @@ describe('importing a per-chat journal', () => { }) ).rejects.toMatchObject({ code: 'journal_closed' }) const reopened = openTestJournalHostDatabase(root) - const reserved = reopened.db.prepare('SELECT block FROM journal_import_blocks').get()?.block expect(rowCount(reopened.db)).toBe(1) - expect(readJournalSessionPointer(reopened.db, IDENTITY.sessionId)).toBeNull() + expect(readJournalSessionEpoch(reopened.db, IDENTITY.sessionId)).toBeNull() + expect(loadTestJournal(root, IDENTITY.sessionId)).toBeNull() const other = await journals.open({ identity: { ...IDENTITY, sessionId: 'session-other' }, @@ -290,7 +289,6 @@ describe('importing a per-chat journal', () => { { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'another chat' }] }, { fence: 1 } ) - expect(readJournalSessionPointer(reopened.db, 'session-other')?.block).not.toBe(reserved) const journal = await openChat() expect(journal.cursor()).toEqual({ epoch, sequence: rows.length }) @@ -298,9 +296,6 @@ describe('importing a per-chat journal', () => { expect(rowCount(reopened.db)).toBe( rows.length + liveTestJournalRows(reopened.db, 'session-other').length ) - expect( - reopened.db.prepare('SELECT count(*) AS total FROM journal_import_blocks').get() - ).toEqual({ total: 0 }) }) it('copies a chat once when two opens of it import at the same time', async () => { @@ -400,7 +395,7 @@ describe('importing a per-chat journal', () => { } expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionPointer(database.db, IDENTITY.sessionId)).toBeNull() + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() expect( database.db.prepare('SELECT count(*) AS total FROM journal_imports').get() ).toMatchObject({ @@ -434,7 +429,7 @@ describe('importing a per-chat journal', () => { }) expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(before)).toBe(true) - expect(readJournalSessionPointer(database.db, IDENTITY.sessionId)).toBeNull() + expect(readJournalSessionEpoch(database.db, IDENTITY.sessionId)).toBeNull() expect(errors).toHaveBeenCalledOnce() }) @@ -460,7 +455,6 @@ describe('importing a per-chat journal', () => { const reopened = await openChat() expect(reopened.cursor()).toEqual({ epoch, sequence: rows.length + 1 }) expect(texts(reopened)).toContain('after the upgrade') - expect(texts(reopened)).not.toContain(JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY.clientMessageId) expect(await leftovers()).toEqual([]) }) @@ -558,7 +552,6 @@ describe('importing a per-chat journal', () => { expect(reopened.epoch).toBe(first.epoch) expect(texts(reopened)).toContain('ORIGINAL HISTORY') expect(texts(reopened)).not.toContain('typed in the older build') - expect(texts(reopened)).not.toContain('continued in an older version of Orca') await journals.closeAll() } const database = openTestJournalHostDatabase(root) @@ -715,16 +708,14 @@ describe('importing a per-chat journal', () => { expect(texts(reopened)).toContain('this build') expect(texts(reopened)).not.toContain('older build, cycle 1') expect(texts(reopened)).not.toContain('cycle 2') - expect(texts(reopened)).not.toContain('continued in an older version of Orca') 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: the file is the copied history plus the older build's - // rows, copied again under the same epoch, with the row that says so. - it('copies again a file an older build carried on under the copied epoch', async () => { + // 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() @@ -732,14 +723,16 @@ describe('importing a per-chat journal', () => { 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(texts(reopened)).toContain('older') - expect(texts(reopened)).toContain('continued in an older version of Orca') - expect(existsSync(legacyDir())).toBe(false) + 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. */ @@ -764,71 +757,4 @@ describe('importing a per-chat journal', () => { .all() .filter((row) => String(row.row_json).includes(text)).length } - - // N-R3.1: the delete failed, this build appended, and an older build then appended to that same - // file. Copying the file again would replace what this build wrote, so it is set aside. - it('keeps this build’s history, and the older file untouched, when both builds wrote past the copy', 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: 'this build' }] }, - { fence: 1 } - ) - 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(texts(reopened)).toContain('this build') - expect(texts(reopened)).not.toContain('older') - expect(texts(reopened)).not.toContain('continued in an older version of Orca') - expect(sharedRowsContaining('this build')).toBeGreaterThan(0) - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) - - // This build rolled its epoch after the copy (a rewind), and the older build then carried the - // leftover file on under the copied epoch: the file no longer holds what this build has. The new - // epoch restarts its sequence, so this build's tip is brought level with the recorded one. - it('keeps this build’s history when it rolled its epoch after the copy', async () => { - const { epoch, rows } = await historyRows() - await writeLegacyJournal(epoch, rows) - removeFails() - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const journal = await openChat() - let cursor = await journal.rollEpoch('handle_forked', 1) - for (let ordinal = 3; cursor.sequence < rows.length; ordinal += 1) { - const appended = await journal.appendItem( - item(ordinal), - { - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text: 'after the rewind' }] - }, - { fence: 1 } - ) - cursor = appended.cursor - } - expect(cursor.sequence).toBe(rows.length) - const rolledEpoch = journal.epoch - await journals.closeAll() - appendOlderRow(epoch, rows, rows.length + 1) - const bytes = await readFile(legacyJournalDatabaseFile(legacyDir())) - await removeWorks() - - const reopened = await openChat() - - expect(rolledEpoch).not.toBe(epoch) - expect(reopened.epoch).toBe(rolledEpoch) - expect(texts(reopened)).toContain('after the rewind') - expect(texts(reopened)).not.toContain('older') - expect(texts(reopened)).not.toContain('continued in an older version of Orca') - expect((await readFile(legacyJournalDatabaseFile(legacyDir()))).equals(bytes)).toBe(true) - }) }) 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 index b7c0c5d2a096..977ee51a02de 100644 --- 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 @@ -3,17 +3,16 @@ // 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 copied again only when it carried -// the copied history on and this build has not written past the copy; any other is set aside, -// never read again (see journal-per-session-reimport.ts). +// 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 into a block `journal_import_blocks` reserves, which no reader follows. Once the -// copied block reads 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 that reserved block, which the next try -// clears and 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. +// 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: @@ -32,7 +31,6 @@ import { planPerSessionImport, isPerSessionJournalSetAside, readPerSessionImportMarker, - reimportedJournalRows, setAsidePerSessionJournal, writePerSessionImportMarker, type PerSessionImportPlan, @@ -46,24 +44,19 @@ import { readLegacyHead, readLegacyRepair, retireLegacyJournal, - type ImportBatch, - type ImportedRow + type ImportBatch } from './journal-per-session-source' import { parseJournalRow } from './journal-row-schema' import { applyJournalRow, createJournalReducerState } from './journal-reducer' import { - allocateJournalBlock, - deleteJournalBlock, - journalRowId, + deleteUnpublishedJournalRows, publishJournalSessionEpoch, readJournalRowsAfter, - readJournalSessionPointer + readJournalSessionEpoch } from './journal-row-table' -const INSERT_ROW = 'INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)' -const SELECT_IMPORT_BLOCK = 'SELECT block FROM journal_import_blocks WHERE session_id = ?' -const RESERVE_IMPORT_BLOCK = 'INSERT INTO journal_import_blocks (session_id, block) VALUES (?, ?)' -const RELEASE_IMPORT_BLOCK = 'DELETE FROM journal_import_blocks WHERE session_id = ?' +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 @@ -73,7 +66,6 @@ export type PerSessionJournalImportDeps = { openSource?: (path: string) => Database.Database /** Deletes one of the per-chat files. */ remove?: (path: string) => void - now?: () => number batchRows?: number } @@ -117,16 +109,16 @@ async function importOnce(input: ImportInput): Promise { - for (let from = 0; ; from += batchRows) { - const last = from + batchRows >= rows.length - yield { rows: rows.slice(from, from + batchRows), last } - if (last) { - return - } - } -} - /** - * Batches into a reserved block no reader follows. Once the block reads back as the file does, one - * transaction publishes the chat's pointer with its repair marker and the import marker. + * 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, - plan: Extract + legacy: PerSessionJournalHead ): Promise { const { sessionId } = input.identity const { epoch } = legacy const repair = readLegacyRepair(source, sessionId) const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - // A second copy is read whole, to follow its rows with the disclosure row. - const reimported = - plan.kind === 'again' - ? reimportedJournalRows({ - sessionId, - epoch, - rows: [...legacyRowBatches(source, sessionId, legacy.epoch, batchRows)].flatMap( - (batch) => batch.rows - ), - now: (input.now ?? Date.now)() - }) - : null - const batches = reimported - ? arrayBatches(reimported, batchRows) - : legacyRowBatches(source, sessionId, legacy.epoch, batchRows) - let block: number | null = null - for (const batch of batches) { - if (block !== null) { + let first = true + for (const batch of legacyRowBatches(source, sessionId, epoch, batchRows)) { + if (!first) { await yieldToEventLoop() } - // Unsynced: no reader follows the reserved block, and the publish's synced commit covers it. - block = input.database.unsyncedTransaction((db) => { - const target = block ?? reserveImportBlock(db, sessionId) + // 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(journalRowId(target, row.seq), row.ts, row.rowJson) + insert.run(sessionId, epoch, row.seq, row.ts, row.rowJson) } - return target }) + first = false } - const target = block ?? input.database.transaction((db) => reserveImportBlock(db, sessionId)) - await verifyCopiedJournal( - input, - reimported - ? arrayBatches(reimported, batchRows) - : legacyRowBatches(source, sessionId, legacy.epoch, batchRows), - { epoch, block: target } - ) + await verifyCopiedJournal(input, legacyRowBatches(source, sessionId, epoch, batchRows), epoch) input.database.transaction((db) => { - const retired = readJournalSessionPointer(db, sessionId) - if (retired) { - deleteJournalBlock(db, retired.block) - } - publishJournalSessionEpoch(db, input.identity, { epoch, block: target }) - db.prepare(RELEASE_IMPORT_BLOCK).run(sessionId) + publishJournalSessionEpoch(db, input.identity, epoch) if (repair) { db.prepare(UPSERT_REPAIR).run( sessionId, @@ -267,17 +225,13 @@ async function copyLegacyJournal( } writePerSessionImportMarker(db, sessionId, legacy) }) - if (plan.kind === 'again') { - // The block this copy replaced. - void input.database.reclaimFreePages() - } } /** Mismatches already logged, so a chat refused on every open logs once. */ const loggedMismatches = new Set() /** - * The copied block, read back from the host's database, against a second read of what was copied: + * 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. @@ -285,11 +239,11 @@ const loggedMismatches = new Set() async function verifyCopiedJournal( input: ImportInput, expected: Iterable, - copied: { epoch: string; block: number } + epoch: string ): Promise { const { sessionId } = input.identity const want = await copyFacts(sessionId, expected) - const got = await copyFacts(sessionId, copiedBatches(input, copied)) + const got = await copyFacts(sessionId, copiedBatches(input, epoch)) if (want === got) { return } @@ -331,14 +285,12 @@ async function copyFacts(sessionId: string, batches: Iterable): Pro return `${epoch}:${tip}:${rows}:${state.items.size}:${state.submissions.size}:${content.digest('hex')}` } -function* copiedBatches( - input: ImportInput, - copied: { epoch: string; block: number } -): Generator { +function* copiedBatches(input: ImportInput, epoch: string): Generator { + const { sessionId } = input.identity const batchRows = input.batchRows ?? IMPORT_BATCH_ROWS - let afterSeq = 0 + let afterSeq = Number.MIN_SAFE_INTEGER for (;;) { - const rows = readJournalRowsAfter(input.database.db, copied, afterSeq, batchRows) + 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 } @@ -348,15 +300,3 @@ function* copiedBatches( afterSeq = lastSeq } } - -/** The block this chat's copy goes into. One an earlier try left behind is emptied and reused. */ -function reserveImportBlock(db: Database.Database, sessionId: string): number { - const staged = db.prepare(SELECT_IMPORT_BLOCK).get(sessionId)?.block - if (typeof staged === 'number') { - deleteJournalBlock(db, staged) - return staged - } - const block = allocateJournalBlock(db) - db.prepare(RESERVE_IMPORT_BLOCK).run(sessionId, block) - return block -} 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 index ffcc6050368a..61ddbfc98f1b 100644 --- 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 @@ -4,22 +4,11 @@ // `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. A file at that epoch past it carried the copied history on, and -// while this build's history still stands exactly as copied, the file is that history plus the -// older build's rows: it is copied again, and the chat says so. -// -// Anything else would replace history this build holds: a file at any other epoch, or beside a -// chat this build founded itself, never held it (an older build started it over, or rolled the -// epoch of a file it kept); and once this build has written past the copy, or rolled its own -// epoch, the file no longer carries what this build has. Such a file is set aside, left on disk as -// it is, and recorded in `journal_set_aside`, so no later open reads it again. +// 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' -import { boundJournalStatusText } from './journal-prompt-body-bounds' -import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { buildJournalItemRow } from './journal-row-builders' -import { parseJournalRow, serializeJournalRow } from './journal-row-schema' -import { readJournalTip, type JournalBlockPointer } from './journal-row-table' export type PerSessionJournalHead = { epoch: string; tip: number } @@ -27,14 +16,6 @@ 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 const JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY = { - provider: 'orca', - clientMessageId: 'journal-continued-in-older-orca' -} as const - -const OLDER_BUILD_DISCLOSURE = - 'This chat was continued in an older version of Orca. Its history includes what was recorded there.' - export function readPerSessionImportMarker( db: Database.Database, sessionId: string @@ -75,59 +56,18 @@ export type PerSessionImportPlan = | { kind: 'copied' } /** Not this build's history: set aside, neither copied nor deleted. */ | { kind: 'kept' } - /** The copied history carried on: copied again, with a row saying so. */ - | { kind: 'again' } /** 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 - current: JournalBlockPointer | null + /** 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' } } - if (!marker && !input.current) { - return { kind: 'first' } - } - // Copied again only while this build's history is exactly what was copied: past that, the copy - // would replace rows this build wrote. - const continued = - marker !== null && - input.legacy.epoch === marker.epoch && - input.current?.epoch === marker.epoch && - readJournalTip(input.db, input.current.block) === marker.tip - return continued ? { kind: 'again' } : { kind: 'kept' } -} - -/** The rows a second copy writes: the file's rows as stored, then the row that says the chat - * continued in an older Orca. */ -export function reimportedJournalRows(input: { - sessionId: string - epoch: string - rows: readonly { seq: number; ts: number; rowJson: string }[] - now: number -}): { seq: number; ts: number; rowJson: string }[] { - const state = createJournalReducerState(input.sessionId, input.epoch) - for (const stored of input.rows) { - const parsed = parseJournalRow(stored.rowJson) - if (!parsed.ok) { - throw new Error(`per-chat journal row ${stored.seq} of ${input.sessionId} is unreadable`) - } - applyJournalRow(state, parsed.row) - } - const disclosure = buildJournalItemRow({ - state, - identity: JOURNAL_OLDER_BUILD_DISCLOSURE_IDENTITY, - body: { kind: 'status', text: boundJournalStatusText(OLDER_BUILD_DISCLOSURE) }, - seq: state.lastSequence + 1, - fence: state.highestFence, - ts: input.now - }) - return [ - ...input.rows, - { seq: disclosure.seq, ts: disclosure.ts, rowJson: serializeJournalRow(disclosure) } - ] + 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 index edc793c3ed91..b5924e1bc9c3 100644 --- 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 @@ -97,7 +97,7 @@ export async function foldLegacyJournal( ): Promise { const fold = startJournalRowFold({ sessionId, - pointer: { epoch: legacy.epoch, block: -1 }, + epoch: legacy.epoch, repairedFrom: source.prepare(HAS_LEGACY_TABLE).get('journal_repairs') ? pendingJournalRepairSequence(source, sessionId, legacy.epoch) : null 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 8fcf71bce923..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 @@ -15,7 +15,7 @@ import type Database from '../../sqlite/sync-database' import type { JournalHostDatabase } from './journal-host-database' -import { deleteJournalRowSuffix, type JournalBlockPointer } from './journal-row-table' +import { deleteJournalRowSuffix } from './journal-row-table' const SELECT_REPAIR = 'SELECT epoch, content_from FROM journal_repairs WHERE session_id = ?' const UPSERT_REPAIR = `INSERT INTO journal_repairs (session_id, epoch, content_from, repaired_at) @@ -50,23 +50,16 @@ export function clearJournalRepairMarker(db: Database.Database, sessionId: strin export function deleteJournalRepairedSuffix(input: { database: JournalHostDatabase sessionId: string - pointer: JournalBlockPointer + epoch: string /** First sequence of the rejected suffix. */ fromSeq: number /** First sequence left free once the suffix is gone. */ contentFrom: number now: number }): number { - const deleted = input.database.transaction((db) => { - const count = deleteJournalRowSuffix(db, input.pointer.block, input.fromSeq) - db.prepare(UPSERT_REPAIR).run( - input.sessionId, - input.pointer.epoch, - input.contentFrom, - input.now - ) - return count + 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 }) - void input.database.reclaimFreePages() - return deleted } 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 8b614f46dc87..a9c53db07780 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,123 +1,69 @@ // Every statement the journal issues against `journal_rows` / `journal_sessions`. // -// A chat's live epoch owns one block of row ids, `[block * 2^32, (block + 1) * 2^32)`, and a row's -// id is `block * 2^32 + seq`. So one chat's rows sit together on their own leaf pages, a replay is -// one range scan, and replacing a chat's history deletes one contiguous range. Measured on the -// largest real chat beside 19 others: 39 ms and 7.5 MB of WAL, against 214 ms and 102 MB for a -// `(session_id, epoch, seq)` key. Ids are computed in Number arithmetic, never bitwise: blocks -// stay below 2^21, so every id stays below 2^53. Columns are always named. +// 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 } -/** Where a chat's live epoch is stored: its UUID, and the block its rows are keyed under. */ -export type JournalBlockPointer = { epoch: string; block: number } - -const BLOCK_SPAN = 2 ** 32 -/** Refused at and above: `(2^21) * 2^32` is 2^53, the first id Number cannot hold exactly. */ -export const JOURNAL_BLOCK_LIMIT = 2 ** 21 -export const JOURNAL_SEQUENCE_LIMIT = BLOCK_SPAN - -const SELECT_POINTER = 'SELECT epoch, block FROM journal_sessions WHERE session_id = ?' -// Past every live block and every block an unfinished import reserved. -const SELECT_NEXT_BLOCK = `SELECT coalesce(max(block), -1) + 1 AS next FROM ( - SELECT block FROM journal_sessions UNION ALL SELECT block FROM journal_import_blocks)` +const SELECT_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' // A new epoch invalidates the saved status, which was computed at a position of the old one. -const PUBLISH_SESSION_EPOCH = `INSERT INTO journal_sessions (session_id, workspace_id, epoch, block) -VALUES (?, ?, ?, ?) +const PUBLISH_SESSION_EPOCH = `INSERT INTO journal_sessions (session_id, workspace_id, epoch) +VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET - workspace_id = excluded.workspace_id, epoch = excluded.epoch, block = excluded.block, + workspace_id = excluded.workspace_id, epoch = excluded.epoch, status_json = NULL, status_seq = NULL` -const INSERT_ROW = 'INSERT INTO journal_rows (id, ts, row_json) VALUES (?, ?, ?)' -const SELECT_ROWS_AFTER = `SELECT id, ts, row_json FROM journal_rows -WHERE id > ? AND id < ? ORDER BY id ASC` +const INSERT_ROW = + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' +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_RANGE = 'DELETE FROM journal_rows WHERE id >= ? AND id < ?' -const SELECT_TIP = 'SELECT max(id) AS tip FROM journal_rows WHERE id >= ? AND id < ?' - -export class JournalKeySpaceError extends Error { - constructor(message: string) { - super(message) - this.name = 'JournalKeySpaceError' - } -} - -export function journalRowId(block: number, seq: number): number { - if (!Number.isInteger(seq) || seq < 1 || seq >= JOURNAL_SEQUENCE_LIMIT) { - throw new JournalKeySpaceError(`sequence ${seq} is outside a journal block`) - } - return block * BLOCK_SPAN + seq -} - -function blockStart(block: number): number { - return block * BLOCK_SPAN -} - -export function readJournalSessionPointer( - db: Database.Database, - sessionId: string -): JournalBlockPointer | null { - const row = db.prepare(SELECT_POINTER).get(sessionId) - return typeof row?.epoch === 'string' && typeof row.block === 'number' - ? { epoch: row.epoch, block: row.block } - : null -} +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 { - return readJournalSessionPointer(db, sessionId)?.epoch ?? null + const epoch = db.prepare(SELECT_EPOCH).get(sessionId)?.epoch + return typeof epoch === 'string' ? epoch : null } -/** A block no chat's live epoch or unfinished import holds. Inside the transaction that uses it. */ -export function allocateJournalBlock(db: Database.Database): number { - const next = db.prepare(SELECT_NEXT_BLOCK).get()?.next - const block = typeof next === 'number' ? next : 0 - if (block >= JOURNAL_BLOCK_LIMIT) { - throw new JournalKeySpaceError('the chat journal has no block left to allocate') - } - return block -} - -/** Points the chat at `epoch` in `block`. Only an epoch change writes this row. */ +/** Points the chat at `epoch`. Only an epoch change writes this row. */ export function publishJournalSessionEpoch( db: Database.Database, identity: { sessionId: string; workspaceId: string }, - pointer: JournalBlockPointer + epoch: string ): void { - db.prepare(PUBLISH_SESSION_EPOCH).run( - identity.sessionId, - identity.workspaceId, - pointer.epoch, - pointer.block - ) + db.prepare(PUBLISH_SESSION_EPOCH).run(identity.sessionId, identity.workspaceId, epoch) } -export function insertJournalRow(db: Database.Database, block: number, row: JournalRow): number { +export function insertJournalRow( + db: Database.Database, + sessionId: string, + row: JournalRow +): number { const rowJson = serializeJournalRow(row) - db.prepare(INSERT_ROW).run(journalRowId(block, row.seq), row.ts, rowJson) + db.prepare(INSERT_ROW).run(sessionId, row.epoch, row.seq, row.ts, rowJson) return Buffer.byteLength(rowJson, 'utf8') } -/** The block's highest sequence, or 0 when it holds no row. */ -export function readJournalTip(db: Database.Database, block: number): number { - const tip = db.prepare(SELECT_TIP).get(blockStart(block), blockStart(block + 1))?.tip - return typeof tip === 'number' ? tip - blockStart(block) : 0 -} - // 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 -/** The live epoch's 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, - pointer: JournalBlockPointer + sessionId: string, + epoch: string ): Generator { - let afterSeq = 0 + let afterSeq = Number.MIN_SAFE_INTEGER for (;;) { - const page = readJournalRowsAfter(db, pointer, afterSeq, EPOCH_ROW_PAGE_SIZE) + const page = readJournalRowsAfter(db, sessionId, epoch, afterSeq, EPOCH_ROW_PAGE_SIZE) yield* page const last = page.at(-1) if (page.length < EPOCH_ROW_PAGE_SIZE || last === undefined) { @@ -129,43 +75,43 @@ export function* iterateJournalEpochRows( export function readJournalRowsAfter( db: Database.Database, - pointer: JournalBlockPointer, + sessionId: string, + epoch: string, afterSeq: number, limit?: number ): JournalStoredRow[] { - const from = blockStart(pointer.block) + Math.max(afterSeq, 0) - const to = blockStart(pointer.block + 1) const rows = limit !== undefined - ? db.prepare(SELECT_ROWS_AFTER_LIMITED).all(from, to, limit) - : db.prepare(SELECT_ROWS_AFTER).all(from, to) + ? 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.id === 'number' && typeof row.ts === 'number' && typeof row.row_json === 'string' - ? [ - { - epoch: pointer.epoch, - seq: row.id - blockStart(pointer.block), - ts: row.ts, - rowJson: row.row_json - } - ] + typeof row.seq === 'number' && typeof row.ts === 'number' && typeof row.row_json === 'string' + ? [{ epoch, seq: row.seq, ts: row.ts, rowJson: row.row_json }] : [] ) } -/** Every row of a retired block, in the transaction that retires it. */ -export function deleteJournalBlock(db: Database.Database, block: number): void { - db.prepare(DELETE_RANGE).run(blockStart(block), blockStart(block + 1)) +/** 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. */ export function deleteJournalRowSuffix( db: Database.Database, - block: number, + sessionId: string, + epoch: string, fromSeq: number ): number { - const deleted = db - .prepare(DELETE_RANGE) - .run(journalRowId(block, Math.max(fromSeq, 1)), blockStart(block + 1)) + const deleted = db.prepare(DELETE_SUFFIX).run(sessionId, epoch, fromSeq) return Number(deleted.changes ?? 0) } 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 bc25757e3c2d..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 @@ -12,7 +12,6 @@ import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-sess import type { JournalHostDatabase } from './journal-host-database' import type { JournalRow } from './journal-row-schema' import { JournalRowWriter } from './journal-row-writer' -import { readJournalSessionPointer } from './journal-row-table' import { openTestJournalHostDatabase, readTestJournalRows, @@ -66,7 +65,6 @@ describe('journal row writer', () => { now: () => 1, serialize: (run) => run(), database: () => database, - block: () => readJournalSessionPointer(database.db, SESSION_ID)?.block ?? -1, readOnly: () => readOnly, highestFence: () => 0, nextSequence: () => sequence, 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 d9c219749c6b..ac920059cd09 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 @@ -8,7 +8,6 @@ export type JournalRowWriterDeps = { now: () => number serialize: (run: () => Promise) => Promise database: () => JournalHostDatabase - block: () => number readOnly: () => boolean highestFence: () => number nextSequence: () => number @@ -24,7 +23,7 @@ export class JournalRowWriter { const row = build(this.deps.nextSequence(), this.deps.now()) assertJournalFence(row.fence, this.deps.highestFence()) // One INSERT: the chat's epoch pointer moves only when the epoch does. - this.deps.database().transaction((db) => insertJournalRow(db, this.deps.block(), row)) + this.deps.database().transaction((db) => insertJournalRow(db, this.deps.sessionId, row)) // COMMIT landed, so the row is durable: adopt it before anything that can // fail. Rejecting here instead would leave the next append reusing a // sequence the table already holds. diff --git a/src/main/native-chat/agent-session-journal/journal-session-status.test.ts b/src/main/native-chat/agent-session-journal/journal-session-status.test.ts index 8a77fae2eea3..7f4799ab0c58 100644 --- a/src/main/native-chat/agent-session-journal/journal-session-status.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-session-status.test.ts @@ -37,9 +37,9 @@ function trustedStatuses(db: Database.Database): { session_id: string; status_js return db .prepare( `SELECT s.session_id, s.status_json FROM journal_sessions s - WHERE s.status_json IS NOT NULL AND s.block * 4294967296 + s.status_seq = ( - SELECT max(r.id) FROM journal_rows r - WHERE r.id >= s.block * 4294967296 AND r.id < (s.block + 1) * 4294967296)` + WHERE s.status_json IS NOT NULL AND s.status_seq = ( + SELECT max(r.seq) FROM journal_rows r + WHERE r.session_id = s.session_id AND r.epoch = s.epoch)` ) .all() as { session_id: string; status_json: string }[] } 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 0e8ad5c5deaf..63bb26dbf72f 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 @@ -31,7 +31,6 @@ export type JournalStoreHost = { owe: (work: () => Promise) => void database: () => JournalHostDatabase state: () => JournalReducerState - block: () => number readOnly: () => boolean setReadOnly: (readOnly: boolean) => void cursor: () => AgentJournalCursor @@ -76,7 +75,6 @@ export function createJournalStoreCollaborators(host: JournalStoreHost): Journal now: host.now, serialize: host.serialize, database: host.database, - block: host.block, readOnly: host.readOnly, highestFence: () => host.state().highestFence, nextSequence: () => host.state().lastSequence + 1, 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 9d9c3624eff3..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 @@ -18,7 +18,6 @@ type JournalDisclosure = JournalRepairDisclosure export function journalStoreLoadedFields(loaded: JournalLoad) { return { state: loaded.state, - block: loaded.block, readOnly: loaded.readOnly, malformedRows: loaded.malformedRows } 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 998829bf53af..3d8b1cdb7485 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 @@ -48,7 +48,7 @@ export async function restoreJournalStore( deleteJournalRepairedSuffix({ database: host.database(), sessionId: host.identity.sessionId, - pointer: { epoch: host.state().epoch, block: host.block() }, + epoch: host.state().epoch, fromSeq, contentFrom, now: host.now() 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 a6d191ed3dbe..ae0b9727b334 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -74,8 +74,6 @@ export class AgentSessionJournal { private readonly mintEpoch: () => string private state: JournalReducerState - /** The block the live epoch's rows are keyed under; moves with the epoch. */ - private block = -1 private readOnly = false private malformedRows = 0 private openedCorrupt = false @@ -112,7 +110,6 @@ export class AgentSessionJournal { owe: (work) => this.queue.owe(work), database: () => this.database, state: () => this.state, - block: () => this.block, readOnly: () => this.readOnly, setReadOnly: (readOnly) => { this.readOnly = readOnly @@ -276,7 +273,8 @@ export class AgentSessionJournal { rowsAfter: (afterSequence) => readJournalRowsAfterCursor( this.database.db, - { epoch: this.state.epoch, block: this.block }, + this.identity.sessionId, + this.state.epoch, afterSequence, limit ), 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 eee68eb4cd06..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 @@ -81,7 +81,7 @@ function observeForwardReads() { const prepare = Database.prototype.prepare vi.spyOn(Database.prototype, 'prepare').mockImplementation(function (this: Database, sql) { const statement = prepare.call(this, sql) - if (sql.includes('id > ?') && !observed.has(statement)) { + if (sql.includes('seq > ?') && !observed.has(statement)) { observed.add(statement) const all = statement.all.bind(statement) vi.spyOn(statement, 'all').mockImplementation((...args) => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 69c165d97adc..0ad14cf32b06 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -25,7 +25,7 @@ import { legacyJournalDatabaseFile } from '../agent-session-journal/journal-paths' import { - readJournalSessionPointer, + readJournalSessionEpoch, type JournalStoredRow } from '../agent-session-journal/journal-row-table' import { previewPerSessionJournal } from '../agent-session-journal/journal-per-session-import' @@ -298,7 +298,7 @@ describe('startup restore of chats still in their per-chat files', () => { const journal = sessions.get(sessionId)!.journal expect(journal.cursor().sequence).toBe(rows.get(sessionId)!.length) expect(texts(journal.snapshot().items)).toContain('reply 1') - expect(readJournalSessionPointer(hostDb(), sessionId)).toBeNull() + expect(readJournalSessionEpoch(hostDb(), sessionId)).toBeNull() expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true) } // Publishing a restored chat's status saves it for the listing; that is no reason to copy. @@ -331,7 +331,7 @@ describe('startup restore of chats still in their per-chat files', () => { readTestJournalRows(hostDb(), 'chat-mid-work', rows[0]!.epoch).slice(0, rows.length) ).toEqual(rows) expect(existsSync(legacyFile('chat-mid-work'))).toBe(false) - expect(readJournalSessionPointer(hostDb(), 'chat-settled')).toBeNull() + expect(readJournalSessionEpoch(hostDb(), 'chat-settled')).toBeNull() expect(existsSync(legacyFile('chat-settled'))).toBe(true) expect(importCount()).toBe(1) } @@ -469,7 +469,7 @@ describe('startup restore of chats still in their per-chat files', () => { let ticking = true const tick = (): void => { seen.push({ - published: readJournalSessionPointer(hostDb(), 'chat-a') !== null, + published: readJournalSessionEpoch(hostDb(), 'chat-a') !== null, copied: Number(hostDb().prepare('SELECT count(*) AS n FROM journal_rows').get()?.n), folded: journal.cursor().sequence }) diff --git a/src/main/sqlite/sqlite-free-page-reclaim.ts b/src/main/sqlite/sqlite-free-page-reclaim.ts deleted file mode 100644 index b887465370d3..000000000000 --- a/src/main/sqlite/sqlite-free-page-reclaim.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Handing freed pages back to the filesystem under `auto_vacuum = INCREMENTAL`. -// -// That mode holds every freed page on the freelist until something asks for it back; nothing asks -// on its own, so a database that deletes and never reclaims only grows. Asking for a whole purge's -// worth at once is one long stall (40 ms per 22 MB freed, measured), so each call is capped. - -import type SyncDatabase from './sync-database' - -export const RECLAIM_PAGES_PER_STEP = 2000 - -export function freePageCount(db: SyncDatabase): number { - return Number(db.pragma('freelist_count', { simple: true }) ?? 0) -} - -/** One bounded step, run to completion. Returns the pages still on the freelist. */ -export function reclaimFreePagesStep( - db: SyncDatabase, - maxPages: number = RECLAIM_PAGES_PER_STEP -): number { - db.pragma(`incremental_vacuum(${maxPages})`) - return freePageCount(db) -} From 297fad4c9471d820aefbe88f8ed44f5cfcaf0dcb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:09:02 -0700 Subject: [PATCH 57/66] fix(native-chat): open a chat journal a newer Orca wrote read-only instead of refusing it After a downgrade, the host's journal database carries a newer user_version. It was refused outright, so every chat's history disappeared. It now opens on a read-only connection, as the per-chat journals did: each chat shows what this build can read, from the database or a per-chat file never copied in, and every write is refused with "Chats were saved by a newer Orca. Update Orca to keep using them." Nothing is written, copied, repaired or founded, and the file stays byte-identical. A table the newer schema changed reads as the same read-only refusal, not damage. --- .../journal-database-schema.ts | 4 +- .../journal-database.test.ts | 30 ++++++---- .../agent-session-journal/journal-database.ts | 34 +++++------ .../journal-host-database.ts | 14 ++++- .../journal-open-failure.test.ts | 4 +- .../journal-open-failure.ts | 6 +- .../journal-store-restore.ts | 38 ++++++++++++- .../journal-store-schema.test.ts | 49 +++++++++++++--- .../journal-write-guards.ts | 5 +- .../agent-session-journal-recovery.test.ts | 56 ++++++++++++++++++- ...uctured-agent-session-host-refusal.test.ts | 17 ++---- .../structured-agent-session-host-refusal.ts | 2 +- 12 files changed, 196 insertions(+), 63 deletions(-) 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 a2c31116a5b5..f083aeca5a1f 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 @@ -10,8 +10,8 @@ // is still the tip of the live epoch. /** 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 latches this build - * out, so every change stays additive. Versions below 3 were never released (journal-database.ts). */ + * (`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 { 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 7d4df9d9864e..960cc95eb90d 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 @@ -7,7 +7,6 @@ import Database from '../../sqlite/sync-database' import { JOURNAL_BUSY_TIMEOUT_MS, JOURNAL_SIZE_LIMIT_BYTES, - JournalDatabaseNewerSchemaError, journalPragmaNumber, openJournalDatabase } from './journal-database' @@ -66,7 +65,7 @@ afterEach(async () => { describe('the host journal database open', () => { it('creates every table and reads back every load-bearing pragma', () => { - const db = openJournalDatabase(dbPath) + const db = openJournalDatabase(dbPath).db try { const tables = db .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") @@ -89,19 +88,28 @@ describe('the host journal database open', () => { } }) - // T5: a newer build's database is refused and left byte-identical. - it('refuses a newer user_version without touching the file', async () => { - const seeded = openJournalDatabase(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) - expect(() => openJournalDatabase(dbPath)).toThrow(JournalDatabaseNewerSchemaError) + const opened = openJournalDatabase(dbPath) + try { + 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 { + opened.db.close() + } expect(await digest(dbPath)).toBe(before) - await expect(stat(`${dbPath}-wal`)).rejects.toThrow() + // 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) }) it('closes the raw connection when schema setup throws', async () => { @@ -115,7 +123,7 @@ describe('the host journal database open', () => { describe('journal row statements', () => { it('serves replay, resume, suffix truncation and an epoch discard', () => { - const db = openJournalDatabase(dbPath) + const db = openJournalDatabase(dbPath).db try { db.exec('BEGIN IMMEDIATE') for (let seq = 1; seq <= 5; seq += 1) { @@ -146,7 +154,7 @@ describe('journal row statements', () => { }) it('deletes only the rows no pointer names', () => { - const db = openJournalDatabase(dbPath) + const db = openJournalDatabase(dbPath).db try { insertJournalRow(db, 'session-1', epochRow(1, 'epoch-copying')) insertJournalRow(db, 'session-2', epochRow(1, 'epoch-live')) @@ -165,7 +173,7 @@ describe('journal row statements', () => { }) it('refuses a duplicate sequence inside one epoch of one chat', () => { - const db = openJournalDatabase(dbPath) + const db = openJournalDatabase(dbPath).db try { insertJournalRow(db, 'session-1', epochRow(1)) expect(() => insertJournalRow(db, 'session-1', epochRow(1))).toThrow() @@ -177,7 +185,7 @@ describe('journal row statements', () => { }) it('moves the epoch pointer in place and forgets the saved status with it', () => { - const db = openJournalDatabase(dbPath) + const db = openJournalDatabase(dbPath).db try { publishJournalSessionEpoch(db, SESSION, 'epoch-1') db.exec("UPDATE journal_sessions SET status_json = '{}', status_seq = 3") 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 1b272b77bfd0..afbea3900052 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -14,31 +14,31 @@ 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' -/** A newer build wrote this database: this build neither reads nor writes it. */ -export class JournalDatabaseNewerSchemaError extends Error { - constructor( - readonly storedVersion: number, - dbPath: string - ) { - super( - `chat journal ${dbPath} uses schema ${storedVersion}; this build knows only up to ${JOURNAL_DB_SCHEMA_VERSION}` - ) - this.name = 'JournalDatabaseNewerSchemaError' - } +export type OpenJournalDatabase = { + db: Database.Database + /** A newer `user_version` was met: this build reads and never writes. */ + readOnly: boolean } export function journalPragmaNumber(db: Database.Database, name: string): number { return Number(db.pragma(name, { simple: true }) ?? 0) } -export function openJournalDatabase(dbPath: string): Database.Database { +export function openJournalDatabase(dbPath: string): OpenJournalDatabase { const probe = new Database(dbPath) + let stored: number + try { + stored = journalPragmaNumber(probe, 'user_version') + } catch (error) { + probe.close() + throw error + } + if (stored > JOURNAL_DB_SCHEMA_VERSION) { + probe.close() + return { db: new Database(dbPath, { readonly: true, fileMustExist: true }), readOnly: true } + } let transferred = false try { - const stored = journalPragmaNumber(probe, 'user_version') - if (stored > JOURNAL_DB_SCHEMA_VERSION) { - throw new JournalDatabaseNewerSchemaError(stored, dbPath) - } if (stored !== 0 && stored < JOURNAL_DB_SCHEMA_VERSION) { // Only unreleased development builds wrote these shapes; left as found, never migrated. throw new Error(`chat journal ${dbPath} uses unreleased schema ${stored}; move it aside`) @@ -47,7 +47,7 @@ export function openJournalDatabase(dbPath: string): Database.Database { createJournalSchema(probe, stored) hardenSqliteDatabaseFiles(dbPath) transferred = true - return probe + return { db: probe, readOnly: false } } finally { if (!transferred) { probe.close() diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index 34c52132973d..1c43e5e7c7b5 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -8,7 +8,12 @@ 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 } from './journal-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' @@ -21,14 +26,17 @@ export function journalDatabasePath(stateDirectory: string): string { 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, - connection: Database.Database + opened: OpenJournalDatabase ) { - this.connection = connection + this.connection = opened.db + this.readOnly = opened.readOnly } static open(stateDirectory: string): JournalHostDatabase { 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 72b7f74d5a9a..c93a78ae25d0 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 @@ -25,7 +25,7 @@ afterEach(async () => { /** What the journal's own open, then a chat's replay, throws for the file as it stands. */ function openFailure(): unknown { try { - const db = openJournalDatabase(journalDatabasePath(root)) + const db = openJournalDatabase(journalDatabasePath(root)).db try { replayJournal(db, 'session-1') } finally { @@ -56,7 +56,7 @@ describe('classifyJournalOpenFailure', () => { it('calls a journal whose pages are damaged corrupt', async () => { const path = journalDatabasePath(root) - const opened = openJournalDatabase(path) + const opened = openJournalDatabase(path).db opened.exec('PRAGMA journal_mode = DELETE') opened.close() const bytes = await readFile(path) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index ce9f1f0c2921..be6222aec134 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 @@ -11,7 +11,6 @@ import { type AgentSessionWireRefusal } from '../../../shared/agent-session-wire-refusals' import { isSqliteCorruption } from '../../sqlite/sqlite-read-failure' -import { JournalDatabaseNewerSchemaError } from './journal-database' import { AgentSessionJournalError } from './journal-write-guards' export type JournalOpenFailure = AgentSessionRefusalReason<'agent_session_journal_unreadable'> @@ -47,10 +46,7 @@ export const JOURNAL_NEWER_SCHEMA_MESSAGE = /** 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 JournalDatabaseNewerSchemaError || - (error instanceof AgentSessionJournalError && error.code === 'journal_read_only') - ) + return error instanceof AgentSessionJournalError && error.code === 'journal_read_only' } /** Why a journal open failed, and the words a released client prints for it. */ 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 3d8b1cdb7485..973874bb842d 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 @@ -7,11 +7,13 @@ // the sequence. 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 async function restoreJournalStore( host: JournalStoreHost, @@ -22,6 +24,9 @@ export async function restoreJournalStore( 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) { @@ -68,3 +73,34 @@ export async 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 027328e98bf4..7c8c15ab655e 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 @@ -5,7 +5,8 @@ // // 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,7 +17,6 @@ import type { } from '../../../shared/agent-session-journal-types' import type Database from '../../sqlite/sync-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { JournalDatabaseNewerSchemaError } from './journal-database' import { journalDatabasePath } from './journal-host-database' import type { AgentSessionJournal } from './journal-store' import { @@ -69,6 +69,12 @@ async function withDatabase(run: (db: Database.Database) => void): Promise } } +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) => { @@ -103,16 +109,45 @@ afterEach(async () => { }) describe('axis 1: the database shape', () => { - // A newer build's database is not opened at all, so nothing here can write to it. - it('refuses a database a newer build stamped, 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 }) await journal.close() await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) - const before = await stat(journalDatabasePath(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 })).rejects.toMatchObject({ + code: 'journal_read_only' + }) + // 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 })).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 }) + await journal.close() + await withDatabase((db) => { + db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_v4') + db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + }) - await expect(open()).rejects.toBeInstanceOf(JournalDatabaseNewerSchemaError) - expect((await stat(journalDatabasePath(root))).size).toBe(before.size) + await expect(open()).rejects.toMatchObject({ code: 'journal_read_only' }) }) it('refuses the schema escape hatch on a store latched by a newer row', async () => { 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 e1869ae0c73b..2a6300a9f877 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 @@ -6,9 +6,10 @@ export class AgentSessionJournalError extends Error { constructor( readonly code: 'journal_read_only' | 'journal_stale_fence' | 'journal_closed', - message: string + message: string, + options?: ErrorOptions ) { - super(message) + super(message, options) this.name = 'AgentSessionJournalError' } } 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 a989b0fdf3d2..b30af43ddae5 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 @@ -3,7 +3,8 @@ // 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' @@ -11,8 +12,11 @@ import type { AgentJournalItemIdentity, AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { JOURNAL_DB_SCHEMA_VERSION } from '../agent-session-journal/journal-database-schema' +import { journalDatabasePath } from '../agent-session-journal/journal-host-database' import { replayJournal } from '../agent-session-journal/journal-open' import { + closeTestJournalHostDatabases, createTrackedJournalOpener, openTestJournalHostDatabase, loadTestJournal, @@ -120,6 +124,12 @@ async function withJournalDatabase( 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) => { @@ -250,6 +260,50 @@ describe('openAgentSessionJournalWithRecovery', () => { }) }) + // 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, + database: openTestJournalHostDatabase(journalDir), + fence: 1, + historyFilePath + }) + journals.track(opened.journal) + + 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: 'journalUnavailable' }, + message: JOURNAL_NEWER_SCHEMA_MESSAGE + } + }) + 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 () => { diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index d98e2d136216..f2685a093ffb 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -13,7 +13,6 @@ import { JournalHostDatabase, journalDatabasePath } from '../native-chat/agent-session-journal/journal-host-database' -import { JOURNAL_NEWER_SCHEMA_MESSAGE } from '../native-chat/agent-session-journal/journal-open-failure' import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry' import Database from '../sqlite/sync-database' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' @@ -99,22 +98,18 @@ describe('a process whose journal will not open', () => { expect(gateRefusal).toThrow('the gate admitted the request') }) - // T5: a newer build's database is refused as one that can clear, and left byte-identical. - it('refuses a database a newer Orca wrote, and leaves it byte-identical', async () => { + // T5: a newer build's database is not refused: the host installs over it read-only, so chats + // stay visible, and the file is left byte-identical. + it('installs over a database a newer Orca wrote, and leaves it byte-identical', async () => { const path = journalDatabasePath(root) const seeded = new Database(path) seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) seeded.close() const before = await digest(path) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - await expect(install()).rejects.toMatchObject({ - refusal: { message: JOURNAL_NEWER_SCHEMA_MESSAGE, details: { reason: 'journalUnavailable' } } - }) - expect(gateRefusal()).toEqual({ - reason: 'journalUnavailable', - message: JOURNAL_NEWER_SCHEMA_MESSAGE - }) + await expect(install()).resolves.toBeDefined() + expect(gateRefusal).toThrow('the gate admitted the request') + await stopStructuredAgentSessionRuntime() expect(await digest(path)).toBe(before) }) }) diff --git a/src/main/runtime/structured-agent-session-host-refusal.ts b/src/main/runtime/structured-agent-session-host-refusal.ts index 3ab3652f56ac..315e9e21ad5d 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.ts @@ -1,5 +1,5 @@ // Why structured chats are refused in this process: its chat journal would not open at install -// (damaged, written by a newer Orca, or unavailable). Everything else goes on without a host. +// (damaged or unavailable). Everything else goes on without a host. import { isAgentSessionRefusalError, From f0c46576f912c845854119f12b9db4a16e3edada Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:12:23 -0700 Subject: [PATCH 58/66] refactor(native-chat): leave the saved listing status to the change that reads it Nothing in this change reads the per-chat listing status column: it was a stored copy of a fact the status feed derives, written after every turn end and cleared on every epoch change. The status_json / status_seq columns, their writer, the saved-status type, the status feed's save and its retry on a cached projection all go, with their tests. The change that lists chats from a saved status adds the column back beside its reader. --- .../first-work-branch-rename.test.ts | 6 +- .../journal-database-schema.ts | 7 +- .../journal-database.test.ts | 11 +- .../journal-row-table.ts | 4 +- .../journal-session-status.test.ts | 115 ------------------ .../journal-session-status.ts | 87 ------------- .../agent-session-journal/journal-store.ts | 21 ---- ...ent-session-restore-without-import.test.ts | 5 +- ...session-status-feed-listing-status.test.ts | 81 ------------ .../structured-agent-session-status-feed.ts | 2 - ...ructured-agent-session-rpc.test-fixture.ts | 4 +- .../structured-agent-session-saved-status.ts | 25 ---- 12 files changed, 10 insertions(+), 358 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-session-status.test.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-session-status.ts delete mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-listing-status.test.ts delete mode 100644 src/shared/structured-agent-session-saved-status.ts diff --git a/src/main/agent-hooks/first-work-branch-rename.test.ts b/src/main/agent-hooks/first-work-branch-rename.test.ts index 0fa8c3489342..6332a4c23f65 100644 --- a/src/main/agent-hooks/first-work-branch-rename.test.ts +++ b/src/main/agent-hooks/first-work-branch-rename.test.ts @@ -103,13 +103,11 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { // A real journal's sequence only ever advances, so the feed's projection // cache must miss on every publish here: this test is about the rename. let sequence = 0 - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only these journal members. const journal = { snapshot: () => ({ items }), lastActivityAt: () => 1, isReadOnly: false, - cursor: () => ({ epoch: 1, sequence: (sequence += 1) }), - saveListingStatus: () => undefined + cursor: () => ({ epoch: 1, sequence: (sequence += 1) }) } as unknown as AgentSessionJournal const pending: Promise[] = [] const observe = vi.fn((summary, options) => { @@ -192,12 +190,10 @@ describe('maybeAutoRenameBranchOnFirstWork', () => { const { deps, setDisplayName, setRenameError } = makeDeps({ getRepo: () => ({ id: REPO_ID, kind: 'folder', path: '/workspace/platform' }) as Repo }) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only these journal members. const journal = { isReadOnly: false, lastActivityAt: () => 1, cursor: () => ({ epoch: 1, sequence: 1 }), - saveListingStatus: () => undefined, snapshot: () => ({ items: [ { body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'Fix auth' }] } }, 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 f083aeca5a1f..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 @@ -6,8 +6,7 @@ // 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. `status_json` is the chat's last settled listing status, trusted only where `status_seq` -// is still the tip of the live epoch. +// again. /** 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, @@ -27,9 +26,7 @@ CREATE TABLE IF NOT EXISTS journal_rows ( CREATE TABLE IF NOT EXISTS journal_sessions ( session_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, - epoch TEXT NOT NULL, - status_json TEXT, - status_seq INTEGER + epoch TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS journal_repairs ( session_id TEXT PRIMARY KEY, 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 960cc95eb90d..10e8234e0a39 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 @@ -184,18 +184,15 @@ describe('journal row statements', () => { } }) - it('moves the epoch pointer in place and forgets the saved status with it', () => { + it('moves the epoch pointer in place', () => { const db = openJournalDatabase(dbPath).db try { publishJournalSessionEpoch(db, SESSION, 'epoch-1') - db.exec("UPDATE journal_sessions SET status_json = '{}', status_seq = 3") publishJournalSessionEpoch(db, SESSION, 'epoch-2') expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-2') - expect( - db - .prepare('SELECT count(*) AS total, max(status_json) AS status FROM journal_sessions') - .get() - ).toMatchObject({ total: 1, status: null }) + expect(db.prepare('SELECT count(*) AS total FROM journal_sessions').get()).toMatchObject({ + total: 1 + }) } finally { db.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 a9c53db07780..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 @@ -10,12 +10,10 @@ import { serializeJournalRow, type JournalRow } from './journal-row-schema' export type JournalStoredRow = { epoch: string; seq: number; ts: number; rowJson: string } const SELECT_EPOCH = 'SELECT epoch FROM journal_sessions WHERE session_id = ?' -// A new epoch invalidates the saved status, which was computed at a position of the old one. const PUBLISH_SESSION_EPOCH = `INSERT INTO journal_sessions (session_id, workspace_id, epoch) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET - workspace_id = excluded.workspace_id, epoch = excluded.epoch, - status_json = NULL, status_seq = NULL` + 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_ROWS_AFTER = `SELECT seq, ts, row_json FROM journal_rows diff --git a/src/main/native-chat/agent-session-journal/journal-session-status.test.ts b/src/main/native-chat/agent-session-journal/journal-session-status.test.ts deleted file mode 100644 index 7f4799ab0c58..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-session-status.test.ts +++ /dev/null @@ -1,115 +0,0 @@ -// T-status: the saved listing status is keyed by the position it was computed at, so a reader can -// trust it only where the journal still stands exactly there — and never across an epoch change. - -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 { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import type { StructuredAgentSessionStatusProjection } from '../../../shared/structured-agent-session-projection' -import type Database from '../../sqlite/sync-database' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import { writeJournalSessionStatus } from './journal-session-status' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } -} - -const IDLE: StructuredAgentSessionStatusProjection = { - status: 'idle', - latestPrompt: 'add a retry', - lastAssistantMessage: 'Done.' -} - -let root: string -const journals = createTrackedJournalOpener() - -/** The one join a restarted host lists from: a saved status stands only at its own tip. */ -function trustedStatuses(db: Database.Database): { session_id: string; status_json: string }[] { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the columns are named in the statement. - return db - .prepare( - `SELECT s.session_id, s.status_json FROM journal_sessions s - WHERE s.status_json IS NOT NULL AND s.status_seq = ( - SELECT max(r.seq) FROM journal_rows r - WHERE r.session_id = s.session_id AND r.epoch = s.epoch)` - ) - .all() as { session_id: string; status_json: string }[] -} - -async function settled(): Promise { - // The write joins the chat's queue behind the rows it describes. - await new Promise((resolve) => setImmediate(resolve)) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-status-')) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('the saved listing status', () => { - it('is saved at the tip it was computed at, and trusted only there', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const { db } = openTestJournalHostDatabase(root) - - journal.saveListingStatus(IDLE) - await settled() - expect(trustedStatuses(db)).toHaveLength(1) - expect(JSON.parse(trustedStatuses(db)[0]!.status_json)).toMatchObject({ - v: 1, - projection: IDLE, - lastActivityAt: journal.lastActivityAt() - }) - - // A row the status was not computed at: a miss, never a stale display. - await journal.appendItem( - { provider: 'orca', clientMessageId: 'note' }, - { kind: 'status', text: 'later' }, - { fence: 0 } - ) - expect(trustedStatuses(db)).toEqual([]) - }) - - it('is written once per change, not once per row', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const { db } = openTestJournalHostDatabase(root) - const prepare = vi.spyOn(db, 'prepare') - - journal.saveListingStatus(IDLE) - journal.saveListingStatus({ ...IDLE }) - await settled() - - expect( - prepare.mock.calls.filter(([sql]) => sql.startsWith('UPDATE journal_sessions')) - ).toHaveLength(1) - }) - - // The epoch case: a new epoch clears the saved status, and a write computed before the change - // cannot land on the new epoch even where its sequence matches the new tip. - it('never survives an epoch change, whatever the old position says', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const { db } = openTestJournalHostDatabase(root) - journal.saveListingStatus(IDLE) - await settled() - const before = journal.cursor() - - await journal.rollEpoch('handle_forked', 1) - expect(journal.cursor().sequence).toBe(before.sequence) - expect(trustedStatuses(db)).toEqual([]) - - writeJournalSessionStatus(db, IDENTITY.sessionId, before, JSON.stringify({ stale: true })) - expect(trustedStatuses(db)).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-session-status.ts b/src/main/native-chat/agent-session-journal/journal-session-status.ts deleted file mode 100644 index 276430ac2a15..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-session-status.ts +++ /dev/null @@ -1,87 +0,0 @@ -// The listing status column: written after the rows it describes commit, keyed by the cursor it -// was computed at. The epoch guard makes a write computed before an epoch change a no-op, and -// every epoch change clears the column (journal-row-table.ts), so no old position survives one. - -import type { AgentJournalCursor } from '../../../shared/agent-session-journal-types' -import type { StructuredAgentSessionStatusProjection } from '../../../shared/structured-agent-session-projection' -import { - STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, - type StructuredAgentSessionSavedStatus -} from '../../../shared/structured-agent-session-saved-status' -import type Database from '../../sqlite/sync-database' -import type { JournalHostDatabase } from './journal-host-database' -import { AgentSessionJournalError } from './journal-write-guards' - -const WRITE_STATUS = `UPDATE journal_sessions SET status_json = ?, status_seq = ? -WHERE session_id = ? AND epoch = ?` - -export function writeJournalSessionStatus( - db: Database.Database, - sessionId: string, - cursor: AgentJournalCursor, - statusJson: string -): void { - db.prepare(WRITE_STATUS).run(statusJson, cursor.sequence, sessionId, cursor.epoch) -} - -/** - * One chat's status column. Written only when the epoch or the projection changes, so about once - * per turn end, and through the chat's own queue so it lands after the rows it describes. - * Bookkeeping: a failed write costs a later boot a miss, never a user action. - */ -export class JournalListingStatusWriter { - /** The epoch and projection last saved or in flight. */ - private saved: string | null = null - /** The projection object behind `saved`: the status feed re-offers its cached one on every read. */ - private offered: { epoch: string; projection: StructuredAgentSessionStatusProjection } | null = - null - - constructor( - private readonly deps: { - sessionId: string - database: () => JournalHostDatabase - serialize: (run: () => Promise) => Promise - } - ) {} - - save( - cursor: AgentJournalCursor, - projection: StructuredAgentSessionStatusProjection, - lastActivityAt: number - ): void { - if (this.offered?.projection === projection && this.offered.epoch === cursor.epoch) { - return - } - const key = `${cursor.epoch}\n${JSON.stringify(projection)}` - const offered = { epoch: cursor.epoch, projection } - if (key === this.saved) { - this.offered = offered - return - } - const status: StructuredAgentSessionSavedStatus = { - v: STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION, - projection, - lastActivityAt - } - this.saved = key - this.offered = offered - this.deps - .serialize(async () => - writeJournalSessionStatus( - this.deps.database().db, - this.deps.sessionId, - cursor, - JSON.stringify(status) - ) - ) - .catch((error: unknown) => { - if (this.saved === key) { - this.saved = null - this.offered = null - } - if (!(error instanceof AgentSessionJournalError && error.code === 'journal_closed')) { - console.warn('[agent-session-journal] saving the listing status failed', error) - } - }) - } -} 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 ae0b9727b334..6f1843a4555e 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -18,8 +18,6 @@ import { agentJournalItemKey } from '../../../shared/agent-session-journal-item- import { currentAgentSessionThreadGoalBySequence } from '../../../shared/agent-session-thread-goal' import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' import { latestStructuredAgentContextFacts } from '../../../shared/structured-agent-session-context-usage' -import type { StructuredAgentSessionStatusProjection } from '../../../shared/structured-agent-session-projection' -import { isSavableStructuredAgentSessionProjection } from '../../../shared/structured-agent-session-saved-status' import { activeStructuredAgentSessionTurnIdBySequence, newestStructuredAgentSessionTurnBySequence @@ -63,7 +61,6 @@ import { createJournalStoreCollaborators } from './journal-store-collaborators' import { journalStoreLoadedFields } from './journal-store-open' import type { JournalItemAppender } from './journal-item-appender' import type { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' -import { JournalListingStatusWriter } from './journal-session-status' export { AgentSessionJournalError } from './journal-write-guards' @@ -79,7 +76,6 @@ export class AgentSessionJournal { private openedCorrupt = false private openedThrough: AgentJournalCursor = { epoch: '', sequence: 0 } private onCommitted: (() => void) | null = null - private readonly listingStatus: JournalListingStatusWriter private readonly queue: JournalWriteQueue private readonly rowWriter: JournalRowWriter private readonly epochController: JournalEpochController @@ -95,11 +91,6 @@ export class AgentSessionJournal { this.state = createJournalReducerState(options.identity.sessionId, '') // Serializes sequence assignment with the durable write behind it. this.queue = new JournalWriteQueue(options.identity.sessionId) - this.listingStatus = new JournalListingStatusWriter({ - sessionId: options.identity.sessionId, - database: () => this.database, - serialize: (run) => this.queue.serialize(run) - }) const collaborators = createJournalStoreCollaborators({ identity: this.identity, legacyDirectory: this.database.legacyDirectoryFor(this.identity), @@ -199,18 +190,6 @@ export class AgentSessionJournal { return this.queue.owing } - /** Saves the settled listing status at the fold's position, after the rows it describes. */ - saveListingStatus(projection: StructuredAgentSessionStatusProjection): void { - // Not yet copied: no host row to annotate, and saving must not force the copy. - if ( - !this.readOnly && - !this.queue.owing && - isSavableStructuredAgentSessionProjection(projection) - ) { - this.listingStatus.save(this.cursor(), projection, this.state.lastActivityAt) - } - } - cursor = (): AgentJournalCursor => ({ epoch: this.state.epoch, sequence: this.state.lastSequence diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts index 0ad14cf32b06..8f9e2c417b33 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restore-without-import.test.ts @@ -301,11 +301,8 @@ describe('startup restore of chats still in their per-chat files', () => { expect(readJournalSessionEpoch(hostDb(), sessionId)).toBeNull() expect(existsSync(legacyJournalDatabaseFile(legacyDirFor(sessionId)))).toBe(true) } - // Publishing a restored chat's status saves it for the listing; that is no reason to copy. for (const sessionId of restored) { - const journal = sessions.get(sessionId)!.journal - journal.saveListingStatus({ status: 'idle', latestPrompt: PROMPT }) - await journal.close() + await sessions.get(sessionId)!.journal.close() } expect(importCount()).toBe(0) // One read of each restored chat's file, and none of the chat that was not restored. diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-listing-status.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-listing-status.test.ts deleted file mode 100644 index e0f671dee07d..000000000000 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed-listing-status.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -// The status feed saves each chat's settled status for the next boot's listing. A save is -// bookkeeping and may fail; the next read of the same status retries it instead of stranding it. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase -} from '../agent-session-journal/journal-host-database-test-support' -import { StructuredAgentSessionStatusFeed } from './structured-agent-session-status-feed' -import { indexedStatusFeedSession } from './structured-agent-session-status-feed-test-session' - -const SESSION = 'listing-status-session' - -let root: string -const journals = createTrackedJournalOpener() - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-status-feed-listing-')) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -async function settled(): Promise { - // The save joins the chat's queue behind the rows it describes. - await new Promise((resolve) => setImmediate(resolve)) -} - -it('retries a failed listing status save on the next read of a cached projection', async () => { - const journal = await journals.open({ - identity: { - sessionId: SESSION, - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - }, - stateDirectory: join(root, SESSION) - }) - await journal.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 1 }, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] }, - { fence: 1 } - ) - const { db } = openTestJournalHostDatabase(join(root, SESSION)) - const prepare = db.prepare.bind(db) - let failures = 1 - vi.spyOn(db, 'prepare').mockImplementation((sql: string) => { - if (sql.startsWith('UPDATE journal_sessions SET status_json') && failures-- > 0) { - throw new Error('disk I/O error') - } - return prepare(sql) - }) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const savedAt = () => - db.prepare('SELECT status_seq FROM journal_sessions WHERE session_id = ?').get(SESSION) - ?.status_seq ?? null - const feed = new StructuredAgentSessionStatusFeed({ - sessions: new Map([[SESSION, indexedStatusFeedSession({ journal })]]), - getRecord: () => null, - now: () => 1_000 - }) - feed.subscribe({ id: 'list-1', emit: () => undefined }) - await settled() - expect(warn).toHaveBeenCalledOnce() - expect(savedAt()).toBeNull() - const snapshot = vi.spyOn(journal, 'snapshot') - - feed.publish(SESSION) - await settled() - - // Retried from the cached projection: the journal is not read again. - expect(snapshot).not.toHaveBeenCalled() - expect(savedAt()).toBe(journal.cursor().sequence) -}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts index b6c5aebf2a4a..22a61a006bb6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-status-feed.ts @@ -342,8 +342,6 @@ export class StructuredAgentSessionStatusFeed { } this.journalProjections.set(journal, projection) } - // Also on a cached projection, so a failed save is retried; an unchanged one is skipped. - journal.saveListingStatus(projection.state.summary) return projection.state } diff --git a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts index bcff13d51bcf..9a0f3902496b 100644 --- a/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts +++ b/src/main/runtime/rpc/methods/structured-agent-session-rpc.test-fixture.ts @@ -98,13 +98,11 @@ function statusFeed(): StructuredAgentSessionStatusFeed { [ STATUS_SESSION, { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the status feed reads only these five members of its journal. journal: { isReadOnly: false, cursor: () => ({ epoch: 'epoch-status', sequence: 2 }), lastActivityAt: () => 2, - snapshot: () => ({ items: STATUS_ITEMS }), - saveListingStatus: () => undefined + snapshot: () => ({ items: STATUS_ITEMS }) } as unknown as AgentSessionJournal, params: { location: { diff --git a/src/shared/structured-agent-session-saved-status.ts b/src/shared/structured-agent-session-saved-status.ts deleted file mode 100644 index 7c51ef3c8f17..000000000000 --- a/src/shared/structured-agent-session-saved-status.ts +++ /dev/null @@ -1,25 +0,0 @@ -// A chat's last settled listing status, saved beside its journal so a restarted host can list it -// without replaying the journal. -// -// Only an idle or no-turn projection is saved, keyed by the journal position it was computed at. -// A reader trusts it only where the journal still stands exactly there; anything else is a miss -// and the journal is opened, so a saved copy can go stale but can never be shown stale. - -import type { StructuredAgentSessionStatusProjection } from './structured-agent-session-projection' - -/** Bump whenever the projection or the reducer changes what an idle or no-turn chat projects. */ -export const STRUCTURED_AGENT_SESSION_STATUS_PROJECTION_VERSION = 1 - -export type StructuredAgentSessionSavedStatus = { - v: number - projection: StructuredAgentSessionStatusProjection - /** The journal's `lastActivityAt()` at that position, which dates the row. */ - lastActivityAt: number -} - -/** Whether a projection is one a saved copy may hold: settled, so no fence can change it. */ -export function isSavableStructuredAgentSessionProjection( - projection: StructuredAgentSessionStatusProjection -): boolean { - return projection.status === null || projection.status === 'idle' -} From c6e7f635ffa0a56c33ea477f63eb2c8f8d05923a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:06:39 -0700 Subject: [PATCH 59/66] fix(native-chat): a chat saved by a newer Orca says to update Orca, not to try again When a newer Orca wrote the chat journal, this build opens it read-only. A send or a Stop was refused with the reason `journalUnavailable`, so today's desktop and phone clients chose the words for an open that can clear: "Orca couldn't open this chat's history right now. Try again." Retrying never cleared it; only updating Orca does. The refusal now names its own reason, `journalWrittenByNewerOrca`, whose words are "Chats were saved by a newer Orca. Update Orca to keep using them." A read refused the same way names it too. An older client does not know the reason, drops it, and falls back to the code's words ("Orca couldn't read this chat's saved history."), and released clients still print the message. --- ...tured-agent-session-thrown-refusal.test.ts | 49 +++++++++++++++++++ .../journal-open-failure.test.ts | 35 ++++++++++++- .../journal-open-failure.ts | 44 +++++++++++------ .../agent-session-journal-recovery.test.ts | 4 +- .../agent-session-write-notice-text.ts | 7 +++ ...nt-session-outbox-rejection-cause.test.tsx | 35 +++++++++++++ ...tured-agent-session-write-refusal.test.tsx | 32 ++++++++++++ .../src/i18n/en-runtime-required.json | 2 + src/renderer/src/i18n/locales/en.json | 2 + src/renderer/src/i18n/locales/es.json | 2 + src/renderer/src/i18n/locales/fr.json | 2 + src/renderer/src/i18n/locales/ja.json | 2 + src/renderer/src/i18n/locales/ko.json | 2 + src/renderer/src/i18n/locales/zh.json | 2 + src/shared/agent-session-refusal-details.ts | 4 +- .../agent-session-refusal-notice.test.ts | 21 +++++++- src/shared/agent-session-refusal-notice.ts | 8 +-- src/shared/agent-session-write-notice-copy.ts | 2 + 18 files changed, 232 insertions(+), 23 deletions(-) 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 index b21a249da608..065845640b3c 100644 --- a/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts +++ b/mobile/src/session/mobile-structured-agent-session-thrown-refusal.test.ts @@ -62,3 +62,52 @@ describe('a refusal the host threw', () => { }) }) }) + +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/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 c93a78ae25d0..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,8 +7,11 @@ import { openJournalDatabase } from './journal-database' import { classifyJournalOpenFailure, createJournalOpenReadRefusals, - journalOpenReadRefusal + journalOpenReadRefusal, + journalOpenRefusal, + journalOpenRefusalError } from './journal-open-failure' +import { AgentSessionJournalError } from './journal-write-guards' import { journalDatabasePath } from './journal-host-database' import { replayJournal } from './journal-open' @@ -162,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 be6222aec134..5eb1ad538c98 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,5 +1,6 @@ // 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' @@ -13,7 +14,10 @@ import { 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 { @@ -36,26 +40,36 @@ export function classifyJournalOpenFailure(error: unknown): JournalOpenFailure { } // Released clients print a refusal's message for a send; it fits a Stop too. -const JOURNAL_OPEN_MESSAGE: Record = { +const JOURNAL_OPEN_MESSAGE: Record = { journalCorrupt: agentSessionWriteNoticeEnglish(['historyUnusable']), - journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']) + journalUnavailable: agentSessionWriteNoticeEnglish(['historyUnavailable', 'tryAgain']), + journalWrittenByNewerOrca: agentSessionWriteNoticeEnglish([ + 'savedByNewerOrca', + 'updateOrcaToKeepUsing' + ]) } -export const JOURNAL_NEWER_SCHEMA_MESSAGE = - 'Chats were saved by a newer Orca. Update Orca to keep using them.' +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: JournalOpenFailure; message: string } { - if (isJournalWrittenByNewerOrca(error)) { - // Clears when this Orca is updated, so it is not damage. - return { reason: 'journalUnavailable', message: JOURNAL_NEWER_SCHEMA_MESSAGE } - } - const reason = classifyJournalOpenFailure(error) +function journalOpenFailureWords(error: unknown): { + reason: JournalRefusalReason + message: string +} { + const reason = journalRefusalReason(error) return { reason, message: JOURNAL_OPEN_MESSAGE[reason] } } @@ -87,7 +101,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 @@ -103,7 +117,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. @@ -121,7 +135,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-wire/agent-session-journal-recovery.test.ts b/src/main/native-chat/agent-session-wire/agent-session-journal-recovery.test.ts index 8b672d50d5cf..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 @@ -250,7 +250,7 @@ describe('openAgentSessionJournalWithRecovery', () => { ok: false, refusal: { code: 'agent_session_journal_unreadable', - details: { reason: 'journalUnavailable' }, + details: { reason: 'journalWrittenByNewerOrca' }, message: JOURNAL_NEWER_SCHEMA_MESSAGE } }) @@ -297,7 +297,7 @@ describe('openAgentSessionJournalWithRecovery', () => { ok: false, refusal: { code: 'agent_session_journal_unreadable', - details: { reason: 'journalUnavailable' }, + details: { reason: 'journalWrittenByNewerOrca' }, message: JOURNAL_NEWER_SCHEMA_MESSAGE } }) diff --git a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts index 812197d6e336..14d265256d2d 100644 --- a/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts +++ b/src/renderer/src/components/native-chat/agent-session-write-notice-text.ts @@ -44,6 +44,13 @@ const SENTENCES: Record string> = { translate('components.native-chat.writeNotice.historyUnusable', COPY.historyUnusable), historyUnavailable: () => translate('components.native-chat.writeNotice.historyUnavailable', COPY.historyUnavailable), + savedByNewerOrca: () => + translate('components.native-chat.writeNotice.savedByNewerOrca', COPY.savedByNewerOrca), + updateOrcaToKeepUsing: () => + translate( + 'components.native-chat.writeNotice.updateOrcaToKeepUsing', + COPY.updateOrcaToKeepUsing + ), unsupported: () => translate('components.native-chat.writeNotice.unsupported', COPY.unsupported), unreachable: () => translate('components.native-chat.writeNotice.unreachable', COPY.unreachable), recordFailed: () => diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx index 4f4b40fb9057..f3386affd1af 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-outbox-rejection-cause.test.tsx @@ -499,3 +499,38 @@ describe('a send the host refused by throwing', () => { ) }) }) + +describe('a send refused on a journal a newer Orca wrote', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + it('says to update Orca, not to try again', async () => { + // As the host answers it (pinned in `journal-open-failure.test.ts`). + mocks.call.mockResolvedValue({ + ok: false, + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) + const { result } = renderHook(() => + useStructuredAgentSessionOutbox({ + sessionId: 'session-1', + target: { kind: 'local' }, + fence: 1, + submissions: [] + }) + ) + + act(() => expect(result.current.send('hello')).toBe(true)) + + await waitFor(() => + expect(shownFailure(result.current.outbox[0])).toBe( + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + ) + ) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx index 8cc467a3d283..db9f70e7ba94 100644 --- a/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx +++ b/src/renderer/src/components/native-chat/use-structured-agent-session-write-refusal.test.tsx @@ -155,6 +155,38 @@ describe('a chat write the host refused', () => { ) }) + it('says to update Orca for a Stop refused on a journal a newer Orca wrote', async () => { + mocks.call.mockImplementation((_target, method) => + method === 'agentSession.options' + ? Promise.resolve(OPTIONS) + : Promise.resolve({ + ok: false, + // As the host answers it (pinned in `journal-open-failure.test.ts`). + refusal: { + code: 'agent_session_journal_unreadable', + message: 'Chats were saved by a newer Orca. Update Orca to keep using them.', + details: { reason: 'journalWrittenByNewerOrca' } + } + }) + ) + const { result } = renderHook(() => + useStructuredAgentSession({ + sessionId: 'session-1', + target: LOCAL_TARGET, + agent: 'claude', + isVisible: true + }) + ) + + await act(async () => { + await expect(result.current.cancel('turn-1')).resolves.toBeNull() + }) + + expect(mocks.toastError).toHaveBeenCalledExactlyOnceWith( + "Chats were saved by a newer Orca. The agent wasn't stopped. Update Orca to keep using them." + ) + }) + it('answers a refused conversation command inline, where the command was typed', async () => { mocks.call.mockImplementation((_target, method) => method === 'agentSession.options' diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json index 56ea7a426404..0a2e6e476bd2 100644 --- a/src/renderer/src/i18n/en-runtime-required.json +++ b/src/renderer/src/i18n/en-runtime-required.json @@ -2790,6 +2790,7 @@ "recordUnreadable": "Orca couldn't read this chat's saved state.", "reopenChat": "Reopen the chat to check again.", "restartFailed": "The agent couldn't restart.", + "savedByNewerOrca": "Chats were saved by a newer Orca.", "settleEarlierMessage": "Wait for your earlier message to go through, or retry it.", "startNewChat": "Start a new chat to continue.", "terminalAgentHoldsChat": "This chat is still open in a terminal agent.", @@ -2798,6 +2799,7 @@ "turnActive": "The agent is still responding.", "unreachable": "Orca couldn't reach the agent.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", + "updateOrcaToKeepUsing": "Update Orca to keep using them.", "waitForBackgroundTasks": "Wait for the background tasks to finish.", "waitForCommand": "Wait for the /compact or /clear to finish.", "waitForStart": "Wait for the agent to finish starting.", diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index eb29d07e1361..616d628a62a2 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -17502,6 +17502,8 @@ "historyUnreadable": "Orca couldn't read this chat's saved history.", "historyUnusable": "Unable to load this chat.", "historyUnavailable": "Orca couldn't open this chat's history right now.", + "savedByNewerOrca": "Chats were saved by a newer Orca.", + "updateOrcaToKeepUsing": "Update Orca to keep using them.", "unsupported": "The Orca running this chat doesn't support this. Update Orca, then try again.", "unreachable": "Orca couldn't reach the agent.", "recordFailed": "Orca couldn't record it in this chat's history.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 71ff52a09eb3..0e5e9f8be0e9 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14951,6 +14951,8 @@ "historyUnreadable": "Orca no pudo leer el historial guardado de este chat.", "historyUnusable": "No se puede cargar este chat.", "historyUnavailable": "Orca no pudo abrir el historial de este chat en este momento.", + "savedByNewerOrca": "Los chats se guardaron con una versión más reciente de Orca.", + "updateOrcaToKeepUsing": "Actualice Orca para seguir usándolos.", "unsupported": "La versión de Orca que ejecuta este chat no lo admite. Actualice Orca y vuelva a intentarlo.", "unreachable": "Orca no pudo comunicarse con el agente.", "recordFailed": "Orca no pudo registrarlo en el historial de este chat.", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 2aa6d6fa69eb..6f088efef984 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -17434,6 +17434,8 @@ "historyUnreadable": "Orca n'a pas pu lire l'historique enregistré de ce chat.", "historyUnusable": "Impossible de charger ce chat.", "historyUnavailable": "Orca n'a pas pu ouvrir l'historique de ce chat pour le moment.", + "savedByNewerOrca": "Les chats ont été enregistrés par une version plus récente d'Orca.", + "updateOrcaToKeepUsing": "Mettez à jour Orca pour continuer à les utiliser.", "unsupported": "L'instance d'Orca qui exécute ce chat ne le prend pas en charge. Mettez Orca à jour, puis réessayez.", "unreachable": "Orca n'a pas pu joindre l'agent.", "recordFailed": "Orca n'a pas pu l'enregistrer dans l'historique de ce chat.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 92110dc80b94..5605173a5433 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -17370,6 +17370,8 @@ "historyUnreadable": "Orca はこのチャットの保存済み履歴を読み取れませんでした。", "historyUnusable": "このチャットを読み込めません。", "historyUnavailable": "Orca は現在このチャットの履歴を開けませんでした。", + "savedByNewerOrca": "チャットは新しいバージョンの Orca で保存されました。", + "updateOrcaToKeepUsing": "引き続き使用するには、Orca を更新してください。", "unsupported": "このチャットを実行している Orca はこの操作に対応していません。Orca を更新してから、もう一度お試しください。", "unreachable": "Orca は Agent に接続できませんでした。", "recordFailed": "Orca はこのチャットの履歴に記録できませんでした。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 88457df8fa57..1d445b10bb28 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -17370,6 +17370,8 @@ "historyUnreadable": "Orca가 이 채팅의 저장된 기록을 읽지 못했습니다.", "historyUnusable": "이 채팅을 불러올 수 없습니다.", "historyUnavailable": "Orca가 지금 이 채팅의 기록을 열지 못했습니다.", + "savedByNewerOrca": "채팅이 최신 버전의 Orca에서 저장되었습니다.", + "updateOrcaToKeepUsing": "계속 사용하려면 Orca를 업데이트하세요.", "unsupported": "이 채팅을 실행 중인 Orca가 이 기능을 지원하지 않습니다. Orca를 업데이트한 후 다시 시도하세요.", "unreachable": "Orca가 에이전트에 연결하지 못했습니다.", "recordFailed": "Orca가 이 채팅의 기록에 저장하지 못했습니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3ff154c804f8..61f9fe126b44 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -17335,6 +17335,8 @@ "historyUnreadable": "Orca 无法读取此聊天的已保存历史记录。", "historyUnusable": "无法加载此聊天。", "historyUnavailable": "Orca 目前无法打开此聊天的历史记录。", + "savedByNewerOrca": "这些聊天由更新版本的 Orca 保存。", + "updateOrcaToKeepUsing": "请更新 Orca 以继续使用这些聊天。", "unsupported": "运行此聊天的 Orca 不支持此操作。请更新 Orca 后重试。", "unreachable": "Orca 无法连接到智能体。", "recordFailed": "Orca 无法将其记录到此聊天的历史记录中。", diff --git a/src/shared/agent-session-refusal-details.ts b/src/shared/agent-session-refusal-details.ts index 6080a772ee2a..8bd73269695c 100644 --- a/src/shared/agent-session-refusal-details.ts +++ b/src/shared/agent-session-refusal-details.ts @@ -95,7 +95,9 @@ export const AGENT_SESSION_REFUSAL_REASONS = { /** SQLite reports the chat's journal damaged or not a database; no retry reads past it. */ 'journalCorrupt', /** Any other failed open, which can clear. */ - 'journalUnavailable' + 'journalUnavailable', + /** A newer Orca wrote the journal, or this chat's rows; only an update writes past it. */ + 'journalWrittenByNewerOrca' ], structured_agent_session_unsupported: [ 'clientCapabilityMissing', diff --git a/src/shared/agent-session-refusal-notice.test.ts b/src/shared/agent-session-refusal-notice.test.ts index a83a95c43880..cdd3639e6a7a 100644 --- a/src/shared/agent-session-refusal-notice.test.ts +++ b/src/shared/agent-session-refusal-notice.test.ts @@ -362,7 +362,10 @@ describe('the notice for every reason a host names', () => { if (words && 'cause' in words) { const retryNow = words.action === 'retry' && words.step === 'tryAgain' expect(words.step !== undefined && !retryNow, cell).toBe( - words.action === 'wait' || words.action === 'actFirst' || words.action === 'goElsewhere' + words.action === 'wait' || + words.action === 'actFirst' || + words.action === 'goElsewhere' || + words.action === 'updateOrca' ) expect(retryNow, cell).toBe(words.cause === 'historyUnavailable') } @@ -534,6 +537,22 @@ describe('a chat whose history the host could not open', () => { 'journalUnavailable', 'send', "Orca couldn't open this chat's history right now. Your message was not sent. Try again." + ], + // Only an update gets past it, so it never says to try again. + [ + 'journalWrittenByNewerOrca', + 'send', + 'Chats were saved by a newer Orca. Your message was not sent. Update Orca to keep using them.' + ], + [ + 'journalWrittenByNewerOrca', + 'stop', + "Chats were saved by a newer Orca. The agent wasn't stopped. Update Orca to keep using them." + ], + [ + 'journalWrittenByNewerOrca', + 'answer', + 'Chats were saved by a newer Orca. Your answer was not sent. Update Orca to keep using them.' ] ] as const)('%s on %s', (reason, write, expected) => { expect(notice({ reason }, write)).toBe(expected) diff --git a/src/shared/agent-session-refusal-notice.ts b/src/shared/agent-session-refusal-notice.ts index 0f4121dc9b31..224b7053b849 100644 --- a/src/shared/agent-session-refusal-notice.ts +++ b/src/shared/agent-session-refusal-notice.ts @@ -200,7 +200,8 @@ const REASON_WORDS = { // No retry reads past damage, and the words name no step: it only can't load. journalCorrupt: causeWords('historyUnusable', 'hostFinding'), // Says its step despite 'retry' unless a Retry stands beside it: the phone often has none. - journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain') + journalUnavailable: causeWords('historyUnavailable', 'retry', 'tryAgain'), + journalWrittenByNewerOrca: causeWords('savedByNewerOrca', 'updateOrca', 'updateOrcaToKeepUsing') }, // Thrown, so a client meets these only as an RPC error; the code's words stand. structured_agent_session_unsupported: { @@ -226,11 +227,12 @@ export function agentSessionRefusalReasonWords( return reason === undefined ? undefined : byReason?.[reason] } -// Each already says the history was not read. +// Each already says the history can't be read here. const HISTORY_CAUSES: ReadonlySet = new Set([ 'historyUnusable', 'historyUnavailable', - 'historyUnreadable' + 'historyUnreadable', + 'savedByNewerOrca' ]) /** A cause, and that the request did not happen unless the cause already says so. */ diff --git a/src/shared/agent-session-write-notice-copy.ts b/src/shared/agent-session-write-notice-copy.ts index d0c36e27a407..97bd97cbf811 100644 --- a/src/shared/agent-session-write-notice-copy.ts +++ b/src/shared/agent-session-write-notice-copy.ts @@ -26,6 +26,8 @@ export const AGENT_SESSION_WRITE_NOTICE_COPY = { historyUnreadable: "Orca couldn't read this chat's saved history.", historyUnusable: 'Unable to load this chat.', historyUnavailable: "Orca couldn't open this chat's history right now.", + savedByNewerOrca: 'Chats were saved by a newer Orca.', + updateOrcaToKeepUsing: 'Update Orca to keep using them.', unsupported: "The Orca running this chat doesn't support this. Update Orca, then try again.", unreachable: "Orca couldn't reach the agent.", recordFailed: "Orca couldn't record it in this chat's history.", From 34add2b54636810ae8f4acf2cc6bcc6443aa7a96 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:10:46 -0700 Subject: [PATCH 60/66] fix(native-chat): a chat journal from an unreleased build reads as unusable, not as retryable A chat journal database stamped with schema 1 or 2 was written only by unreleased development builds of this change. Opening it threw a plain error, which every chat reported as "Orca couldn't open this chat's history right now. Try again." Retrying never cleared it. It now throws a named error that is classified as unusable, so every chat says "Unable to load this chat." The one log line names the file, says an unreleased development build wrote it, and says to move it aside. Nothing migrates or renames it. --- .../journal-database.test.ts | 2 ++ .../agent-session-journal/journal-database.ts | 7 +++-- .../journal-open-failure.ts | 14 ++++++++-- ...uctured-agent-session-host-refusal.test.ts | 28 +++++++++++++++++++ 4 files changed, 47 insertions(+), 4 deletions(-) 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 10e8234e0a39..464850433a9d 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 @@ -12,6 +12,7 @@ import { } from './journal-database' import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { journalDatabasePath } from './journal-host-database' +import { JournalUnreleasedSchemaError } from './journal-open-failure' import { deleteJournalEpochRows, deleteJournalRowSuffix, @@ -244,6 +245,7 @@ INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) 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 afbea3900052..677247660116 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -7,6 +7,7 @@ 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' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 /** Bounds the WAL a checkpoint leaves behind; SQLite truncates it back to this after a reset. */ @@ -40,8 +41,10 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { let transferred = false try { if (stored !== 0 && stored < JOURNAL_DB_SCHEMA_VERSION) { - // Only unreleased development builds wrote these shapes; left as found, never migrated. - throw new Error(`chat journal ${dbPath} uses unreleased schema ${stored}; move it aside`) + // 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) 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 5eb1ad538c98..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 @@ -24,14 +24,24 @@ 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) || current instanceof JournalImportMismatchError) { + if ( + isSqliteCorruption(current) || + current instanceof JournalImportMismatchError || + current instanceof JournalUnreleasedSchemaError + ) { return 'journalCorrupt' } current = current instanceof Error ? current.cause : undefined diff --git a/src/main/runtime/structured-agent-session-host-refusal.test.ts b/src/main/runtime/structured-agent-session-host-refusal.test.ts index f2685a093ffb..689ac3df969c 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.test.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.test.ts @@ -98,6 +98,34 @@ describe('a process whose journal will not open', () => { expect(gateRefusal).toThrow('the gate admitted the request') }) + // A database from an unreleased build of this change: never migrated or renamed, so it reads as + // damage, and the log says where it is and what to do. + it('refuses a database an unreleased build wrote as unusable, and says to move it aside', async () => { + const path = journalDatabasePath(root) + const earlier = new Database(path) + earlier.exec('CREATE TABLE journal_rows (id INTEGER PRIMARY KEY, row_json TEXT NOT NULL)') + earlier.pragma('user_version = 2') + earlier.close() + const before = await digest(path) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect(install()).rejects.toMatchObject({ + refusal: { message: 'Unable to load this chat.', details: { reason: 'journalCorrupt' } } + }) + } + expect(gateRefusal()).toEqual({ + reason: 'journalCorrupt', + message: 'Unable to load this chat.' + }) + expect(warn).toHaveBeenCalledOnce() + const logged = String(warn.mock.calls[0]?.[1]) + expect(logged).toContain(path) + expect(logged).toContain('unreleased development build') + expect(logged).toContain('move the file aside') + expect(await digest(path)).toBe(before) + }) + // T5: a newer build's database is not refused: the host installs over it read-only, so chats // stay visible, and the file is left byte-identical. it('installs over a database a newer Orca wrote, and leaves it byte-identical', async () => { From 5d84fe57a020caceb629eb6df70135ee250da6b2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:12:02 -0700 Subject: [PATCH 61/66] docs(native-chat): drop the second-Orca-owns-the-chats case from three comments The chat-only owner lock is gone, so only a chat journal that will not open leaves a runtime unable to list its chats. --- src/renderer/src/runtime/host-session-snapshot-authority.ts | 6 +++--- .../local-structured-session-unverifiable-inventory.test.ts | 6 +++--- src/shared/runtime-session-contracts.ts | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/runtime/host-session-snapshot-authority.ts b/src/renderer/src/runtime/host-session-snapshot-authority.ts index 8a894203bd30..336bdf9fe6d9 100644 --- a/src/renderer/src/runtime/host-session-snapshot-authority.ts +++ b/src/renderer/src/runtime/host-session-snapshot-authority.ts @@ -51,9 +51,9 @@ export function hostSnapshotAffirmsClientHostedPages( * Whether a snapshot's `agent-session` rows are the host's answer about which chats exist. * * Narrower than {@link hostSnapshotAffirmsWorktreeContents} for the same reason as client-hosted - * pages: a runtime refused its chats (another Orca owns them, or the journal will not open) is - * authoritative about terminals but cannot list a single chat. Its empty chat set is "cannot - * tell", and culling on it would delete tabs whose chats are safe on disk. + * pages: a runtime whose chat journal will not open is authoritative about terminals but cannot + * list a single chat. Its empty chat set is "cannot tell", and culling on it would delete tabs + * whose chats are safe on disk. */ export function hostSnapshotAffirmsAgentSessions(snapshot: AgentSessionPublication): boolean { return hostSnapshotAffirmsWorktreeContents(snapshot) && !snapshot.agentSessionsUnverifiable diff --git a/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts b/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts index 5159be9f6d3f..ad2e7380b8b4 100644 --- a/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts +++ b/src/renderer/src/runtime/local-structured-session-unverifiable-inventory.test.ts @@ -1,8 +1,8 @@ // @vitest-environment happy-dom -// A runtime refused its chats (another Orca owns them, or the journal will not open) still answers -// the tab inventory, but cannot list a single chat. Its empty chat set must not delete the saved -// chat tabs, or the next session save persists their placement away. +// A runtime whose chat journal will not open still answers the tab inventory, but cannot list a +// single chat. Its empty chat set must not delete the saved chat tabs, or the next session save +// persists their placement away. import { afterEach, describe, expect, it } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 7d8dba5849e0..1d59572f9465 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -265,9 +265,9 @@ export type RuntimeMobileSessionTabsResult = { */ clientHostedPagesUnreconciled?: true /** - * Set while this runtime has no structured-chat host that can say which chats exist: another - * Orca process owns them, or the chat journal will not open. The snapshot is still authoritative - * about everything else, but its missing `agent-session` rows mean "cannot tell", not "closed". + * Set while this runtime has no structured-chat host that can say which chats exist because the + * chat journal will not open. The snapshot is still authoritative about everything else, but its + * missing `agent-session` rows mean "cannot tell", not "closed". * * Cleared by the first tab restore a host answers. Not bounded by a deadline: the chats are * durable on disk, so holding their tabs strands nothing. From bcd99f018b00d8d17122c4cf5c258c892fea60a6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:13:30 -0700 Subject: [PATCH 62/66] docs(native-chat): correct three chat-journal comments the redesign left behind A per-chat file left without its WAL is set aside, not copied again; nothing runs an incremental vacuum yet, so the auto_vacuum mode is kept for a later pass; and the idle sweep drops a chat's in-memory fold, since a chat holds no journal connection. --- .../native-chat/agent-session-journal/journal-database.ts | 4 ++-- .../agent-session-journal/journal-per-session-source.ts | 2 +- .../agent-session-wire/structured-agent-session-idle-sweep.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) 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 677247660116..600eaab9dbe1 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -60,8 +60,8 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { function configureJournalPragmas(db: Database.Database, stored: number): void { if (stored === 0) { - // Only takes on an empty file, and only before WAL: it is what lets freed pages go back in - // bounded steps rather than a full VACUUM, which could never be switched on later. + // 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') 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 index b5924e1bc9c3..c2c61472ef14 100644 --- 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 @@ -128,7 +128,7 @@ export function retireLegacyJournal( 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 copied again. + // 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) } 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. From 9d7d51bcf339111bf3acf0bc3b57b8760347f5ec Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:17:11 -0700 Subject: [PATCH 63/66] refactor(native-chat): stop exporting chat-journal names nothing imports Each is used only inside its own module now; the teardown's export served a deleted test. --- .../agent-session-journal/journal-host-database.ts | 2 +- src/main/native-chat/agent-session-journal/journal-open.ts | 4 ++-- src/main/native-chat/agent-session-journal/journal-paths.ts | 2 +- .../agent-session-journal/journal-per-session-import.ts | 2 +- .../agent-session-journal/journal-per-session-source.ts | 2 +- .../structured-agent-session-host-teardown.ts | 2 +- src/main/runtime/structured-agent-session-host-refusal.ts | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.ts b/src/main/native-chat/agent-session-journal/journal-host-database.ts index 1c43e5e7c7b5..b6999e4ef559 100644 --- a/src/main/native-chat/agent-session-journal/journal-host-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-host-database.ts @@ -18,7 +18,7 @@ import { journalOpenRefusalError } from './journal-open-failure' import { journalDirectoryFor } from './journal-paths' import { AgentSessionJournalError } from './journal-write-guards' -export const JOURNAL_DATABASE_FILE = 'agent-session-journal.db' +const JOURNAL_DATABASE_FILE = 'agent-session-journal.db' export function journalDatabasePath(stateDirectory: string): string { return join(stateDirectory, JOURNAL_DATABASE_FILE) 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 339b1984024c..ae753839c40d 100644 --- a/src/main/native-chat/agent-session-journal/journal-open.ts +++ b/src/main/native-chat/agent-session-journal/journal-open.ts @@ -53,7 +53,7 @@ export function replayJournal(db: Database.Database, sessionId: string): Journal } /** Folds one epoch's stored rows, in sequence order, wherever they are stored. */ -export function foldJournalRows( +function foldJournalRows( input: JournalRowFoldInput & { rows: Iterable<{ seq: number; rowJson: string }> } ): JournalLoad { const fold = startJournalRowFold(input) @@ -65,7 +65,7 @@ export function foldJournalRows( return fold.finish() } -export type JournalRowFoldInput = { +type JournalRowFoldInput = { sessionId: string epoch: string /** The sequence a pending repair on this epoch left free. */ 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 173f36909f37..cb7d44e8c83f 100644 --- a/src/main/native-chat/agent-session-journal/journal-paths.ts +++ b/src/main/native-chat/agent-session-journal/journal-paths.ts @@ -29,7 +29,7 @@ export function journalDirectoryFor( ) } -export const LEGACY_JOURNAL_DATABASE_FILE = 'journal.db' +const LEGACY_JOURNAL_DATABASE_FILE = 'journal.db' /** The per-chat SQLite file inside the directory `journalDirectoryFor` names. */ export function legacyJournalDatabaseFile(journalDir: string): string { 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 index 977ee51a02de..5f85fdded437 100644 --- 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 @@ -62,7 +62,7 @@ VALUES (?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET epoch = excluded.epoch, content_from = excluded.content_from, repaired_at = excluded.repaired_at` -export type PerSessionJournalImportDeps = { +type PerSessionJournalImportDeps = { openSource?: (path: string) => Database.Database /** Deletes one of the per-chat files. */ remove?: (path: string) => void 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 index c2c61472ef14..dc548e2f3dd0 100644 --- 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 @@ -24,7 +24,7 @@ const HAS_LEGACY_TABLE = "SELECT 1 AS present FROM sqlite_master WHERE type = 't const SELECT_LEGACY_REPAIR = 'SELECT epoch, content_from, repaired_at FROM journal_repairs WHERE session_id = ?' -export type ImportedRow = { seq: number; ts: number; rowJson: string } +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. */ 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 63f90828534d..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 @@ -98,7 +98,7 @@ export function structuredAgentSessionHostTeardownPhases(collaborators: { ] } -export async function tearDownStructuredAgentSessionHost(input: { +async function tearDownStructuredAgentSessionHost(input: { phases: readonly StructuredAgentSessionTeardownPhase[] sessions: Map retainSessionIds?: ReadonlySet diff --git a/src/main/runtime/structured-agent-session-host-refusal.ts b/src/main/runtime/structured-agent-session-host-refusal.ts index 315e9e21ad5d..30465c0fb129 100644 --- a/src/main/runtime/structured-agent-session-host-refusal.ts +++ b/src/main/runtime/structured-agent-session-host-refusal.ts @@ -14,7 +14,7 @@ export function structuredAgentSessionHostRefusal(): AgentSessionRefusalError | } /** Whether `error` is the refusal structured requests are getting right now. */ -export function isStructuredAgentSessionHostRefusal(error: unknown): boolean { +function isStructuredAgentSessionHostRefusal(error: unknown): boolean { const refusal = installRefusal return ( refusal !== null && From 8c9d7055cf51c7630eeb470f47300b8ee75aa24f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:18:29 -0700 Subject: [PATCH 64/66] test(native-chat): name the version-0 test for what it covers, and check every journal table The test named 'migrates an older user_version forward' covers only a version-0 file that already has its tables; versions 1 and 2 are refused. The table test now also checks journal_imports and journal_set_aside. --- .../agent-session-journal/journal-database.test.ts | 8 +++++++- .../agent-session-journal/journal-store-schema.test.ts | 3 ++- 2 files changed, 9 insertions(+), 2 deletions(-) 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 464850433a9d..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 @@ -73,7 +73,13 @@ describe('the host journal database open', () => { .all() .map((entry) => (entry as { name: string }).name) expect(tables).toEqual( - expect.arrayContaining(['journal_repairs', 'journal_rows', 'journal_sessions']) + 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) 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 374a965df83f..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 @@ -177,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, From 4b78201266c2c4d8add632a0736f227711373265 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:19:34 -0700 Subject: [PATCH 65/66] fix(startup): a second dev launch's exit line no longer claims it focused a window The running dev instance may be a background launch or a server, which show no window. The line now says only that this launch passed its request to that instance. --- src/main/startup/single-instance-lock.test.ts | 2 ++ src/main/startup/single-instance-lock.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/startup/single-instance-lock.test.ts b/src/main/startup/single-instance-lock.test.ts index 942ed8ff1b09..f1b5abfe0a35 100644 --- a/src/main/startup/single-instance-lock.test.ts +++ b/src/main/startup/single-instance-lock.test.ts @@ -162,6 +162,8 @@ describe('logSingleInstanceLockFailure', () => { expect(line.trimEnd()).not.toContain('\n') expect(line).toContain(`the profile at ${userDataPath}`) expect(line).toContain('ORCA_DEV_USER_DATA_PATH=') + // A background or serve instance shows no window, so the line claims none. + expect(line).not.toMatch(/focus|window/i) }) }) diff --git a/src/main/startup/single-instance-lock.ts b/src/main/startup/single-instance-lock.ts index ff3d4d9f2810..521b921408e3 100644 --- a/src/main/startup/single-instance-lock.ts +++ b/src/main/startup/single-instance-lock.ts @@ -87,7 +87,7 @@ export function singleInstanceLockFailureMessage(options: { if (!options.isDevDesktop) { return SINGLE_INSTANCE_LOCK_FAILURE_MESSAGE } - return `[single-instance] Another Orca dev instance is already running on the profile at ${options.userDataPath}; exiting this launch after focusing its window. To run another dev copy at the same time, give it its own profile: ORCA_DEV_USER_DATA_PATH= pnpm dev` + return `[single-instance] Another Orca dev instance is already running on the profile at ${options.userDataPath}; exiting this launch after passing it this launch's request. To run another dev copy at the same time, give it its own profile: ORCA_DEV_USER_DATA_PATH= pnpm dev` } export function logSingleInstanceLockFailure( From 5ca53728160accaecb90785d5f999ffa0d06a3d1 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 14:24:22 -0700 Subject: [PATCH 66/66] fix(native-chat): a failed structured-chat install closes the journal connection it opened The install opened the chat journal database and closed it only if the record store then failed to open. A later failure, such as the model catalog wiring or the host constructor, left the connection open, and the next install opened a second one in the same process. Every failure after the open now closes it. --- ...ured-agent-session-install-failure.test.ts | 68 +++++++++++++++++++ .../structured-agent-session-runtime.ts | 22 ++++-- 2 files changed, 83 insertions(+), 7 deletions(-) create mode 100644 src/main/runtime/structured-agent-session-install-failure.test.ts diff --git a/src/main/runtime/structured-agent-session-install-failure.test.ts b/src/main/runtime/structured-agent-session-install-failure.test.ts new file mode 100644 index 000000000000..53720659ec41 --- /dev/null +++ b/src/main/runtime/structured-agent-session-install-failure.test.ts @@ -0,0 +1,68 @@ +// An install that fails after it opened the chat journal closes that connection, so the next +// install does not leave a second one open in the same process. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type * as ModelCatalogWiring from './structured-agent-model-catalog-wiring' +import { + ensureStructuredAgentSessionHost, + stopStructuredAgentSessionRuntime +} from './structured-agent-session-runtime' + +const mocks = vi.hoisted(() => ({ failWiring: vi.fn(() => false) })) + +// The last step before the host is built: a throw here comes after both stores opened. +vi.mock('./structured-agent-model-catalog-wiring', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + modelCatalogHostDeps: async (input: Parameters[0]) => { + if (mocks.failWiring()) { + throw new Error('model catalog wiring failed') + } + return actual.modelCatalogHostDeps(input) + } + } +}) + +let root: string + +function install(): ReturnType { + return ensureStructuredAgentSessionHost({ + stateDirectory: root, + hostId: 'local', + claimKeyId: 'key-1', + resolveWorkspacePath: async () => root, + resolveEnvironment: async () => ({}), + resolveClaudeAuthPolicy: () => ({ stripAuthEnv: true }) + }) +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-structured-install-failure-')) +}) + +afterEach(async () => { + await stopStructuredAgentSessionRuntime().catch(() => undefined) + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) +}) + +describe('an install that fails after opening the chat journal', () => { + it('closes the journal connection it opened, and the next install opens its own', async () => { + const open = vi.spyOn(JournalHostDatabase, 'open') + mocks.failWiring.mockReturnValueOnce(true) + + await expect(install()).rejects.toThrow('model catalog wiring failed') + const failed = open.mock.results[0]?.value + expect(failed).toBeInstanceOf(JournalHostDatabase) + expect(failed.isClosed).toBe(true) + + await expect(install()).resolves.toBeDefined() + expect(open).toHaveBeenCalledTimes(2) + expect(open.mock.results[1]?.value.isClosed).toBe(false) + }) +}) diff --git a/src/main/runtime/structured-agent-session-runtime.ts b/src/main/runtime/structured-agent-session-runtime.ts index 1366c3dc6cf9..24735c721cc2 100644 --- a/src/main/runtime/structured-agent-session-runtime.ts +++ b/src/main/runtime/structured-agent-session-runtime.ts @@ -40,6 +40,7 @@ import { type ClaudeManagedAccountGateSettings } from '../native-chat/claude-structured-managed-account-support' import { AgentSessionRecordStore } from './agent-session-record-store' +import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' import { openStructuredAgentSessionJournalDatabase } from './structured-agent-session-journal-open' import { agentSessionStorePath } from './agent-session-record-store-file' import { @@ -194,18 +195,25 @@ async function install(deps: StructuredAgentSessionRuntimeDeps): Promise { + const envResolvers = createStructuredAgentEnvironmentResolvers(deps) + const { resolveCodexEnvironment, resolveClaudeInheritedEnv } = envResolvers + const store = await AgentSessionRecordStore.open({ + directory: join(deps.stateDirectory, RECORD_STORE_DIR_NAME), + hostId: deps.hostId + }) let host: StructuredAgentSessionHost | null = null const lifecycle = createStructuredAgentSessionLifecycleDelivery({ handle: (event) => host?.handleAdapterEvent(event),