From fca6a945c42fa2f8760e8a9bb9069d2b9049d365 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 15 Oct 2026 08:06:13 -0700 Subject: [PATCH] ci: reduce fixture startup and shutdown waits (#29163) * ci: reduce fixture startup and shutdown waits * test: drain journals and restore maintenance timers --- config/scripts/oxc-cli-invocation.test.mjs | 1 + .../acp/acp-structured-broken-stream.test.ts | 1 + ...ex-app-server-teardown.integration.test.ts | 12 +- .../command-receipt-schema.spec.ts | 178 ++++ .../command-receipt-schema.test.ts | 174 ---- .../command-receipt-table.spec.ts | 364 ++++++++ .../command-receipt-table.test.ts | 357 -------- .../command-receipt-transaction.spec.ts | 196 ++++ .../command-receipt-transaction.test.ts | 191 ---- ...journal-atomic-terminal-settlement.spec.ts | 421 +++++++++ ...journal-atomic-terminal-settlement.test.ts | 414 --------- .../journal-crash-boundary.spec.ts | 499 +++++++++++ .../journal-crash-boundary.test.ts | 489 ---------- .../journal-database-contracts.test.ts | 22 + .../journal-database.spec.ts | 264 ++++++ .../journal-database.test.ts | 258 ------ .../journal-dispatch-reason-bound.spec.ts | 98 ++ .../journal-dispatch-reason-bound.test.ts | 96 -- .../journal-epoch-replacement.spec.ts | 139 +++ .../journal-epoch-replacement.test.ts | 135 --- .../journal-host-database.spec.ts | 271 ++++++ .../journal-host-database.test.ts | 260 ------ .../journal-item-provenance.spec.ts | 130 +++ .../journal-item-provenance.test.ts | 128 --- .../journal-lifecycle-batch-appender.spec.ts | 184 ++++ .../journal-lifecycle-batch-appender.test.ts | 182 ---- .../journal-pi-context-import.spec.ts | 100 +++ .../journal-pi-context-import.test.ts | 98 -- .../journal-producer-inheritance.spec.ts | 234 +++++ .../journal-producer-inheritance.test.ts | 232 ----- .../journal-queued-message-contracts.test.ts | 20 + .../journal-queued-rejection-batch.spec.ts | 129 +++ .../journal-queued-rejection-batch.test.ts | 130 --- .../journal-recovery-contracts.test.ts | 22 + .../journal-restart-reconciliation.spec.ts | 335 +++++++ .../journal-restart-reconciliation.test.ts | 333 ------- .../journal-row-context-usage.spec.ts | 167 ++++ .../journal-row-context-usage.test.ts | 165 ---- .../journal-row-schema-version.spec.ts | 75 ++ .../journal-row-schema-version.test.ts | 68 -- .../journal-row-writer.spec.ts | 129 +++ .../journal-row-writer.test.ts | 127 --- .../journal-settlement-contracts.test.ts | 22 + .../journal-stop-and-resume-rows.spec.ts | 128 +++ .../journal-stop-and-resume-rows.test.ts | 126 --- .../journal-store-schema.spec.ts | 250 ++++++ .../journal-store-schema.test.ts | 243 ----- .../journal-store.spec.ts | 507 +++++++++++ .../journal-store.test.ts | 505 ----------- .../journal-subagent-item.spec.ts | 159 ++++ .../journal-subagent-item.test.ts | 153 ---- .../journal-subagent-liveness.spec.ts | 381 ++++++++ .../journal-subagent-liveness.test.ts | 376 -------- .../journal-submission-positions.spec.ts | 176 ++++ .../journal-submission-positions.test.ts | 171 ---- .../journal-submission-queued-link.spec.ts | 160 ++++ .../journal-submission-queued-link.test.ts | 158 ---- ...nal-terminal-settlement-validation.spec.ts | 402 +++++++++ ...nal-terminal-settlement-validation.test.ts | 396 --------- .../journal-unknown-content.spec.ts | 285 ++++++ .../journal-unknown-content.test.ts | 272 ------ .../journal-unknown-row-kind.spec.ts | 276 ++++++ .../journal-unknown-row-kind.test.ts | 272 ------ .../journal-unsent-send-hold.spec.ts | 508 +++++++++++ .../journal-unsent-send-hold.test.ts | 490 ---------- .../journal-write-queue.spec.ts | 220 +++++ .../journal-write-queue.test.ts | 216 ----- ...queued-message-bookkeeping-failure.spec.ts | 225 +++++ ...queued-message-bookkeeping-failure.test.ts | 223 ----- .../queued-message-delivered-echo.spec.ts | 201 +++++ .../queued-message-delivered-echo.test.ts | 199 ----- .../queued-message-pause.spec.ts | 756 ++++++++++++++++ .../queued-message-pause.test.ts | 745 ---------------- .../queued-message-store.spec.ts | 838 ++++++++++++++++++ .../queued-message-store.test.ts | 836 ----------------- .../queued-message-text-update.spec.ts | 183 ++++ .../queued-message-text-update.test.ts | 181 ---- .../durable-settings-write.spec.ts | 89 ++ .../durable-settings-write.test.ts | 96 -- .../profile-state-caller-cancellation.spec.ts | 196 ++++ .../profile-state-caller-cancellation.test.ts | 203 ----- .../profile-state-durable-writes.test.ts | 27 + ...rofile-state-maintenance-contracts.test.ts | 29 + ...le-state-maintenance-final-failure.spec.ts | 81 ++ ...le-state-maintenance-final-failure.test.ts | 88 -- ...profile-state-maintenance-recovery.spec.ts | 141 +++ ...profile-state-maintenance-recovery.test.ts | 145 --- .../profile-state-maintenance.spec.ts | 297 +++++++ .../profile-state-maintenance.test.ts | 301 ------- ...-state-pty-retirement-finalization.spec.ts | 196 ++++ ...-state-pty-retirement-finalization.test.ts | 199 ----- .../profile-state-worker-coordination.spec.ts | 329 +++++++ .../profile-state-worker-coordination.test.ts | 335 ------- ...file-state-worker-secret-retention.spec.ts | 139 +++ ...file-state-worker-secret-retention.test.ts | 146 --- .../profile-state-write-batching.spec.ts | 244 +++++ .../profile-state-write-batching.test.ts | 251 ------ .../pty-binding-async-durability.spec.ts | 260 ++++++ .../pty-binding-async-durability.test.ts | 267 ------ ...y-binding-created-surface-rollback.spec.ts | 128 +++ ...y-binding-created-surface-rollback.test.ts | 135 --- .../pty-binding-restart-retirement.spec.ts | 76 ++ .../pty-binding-restart-retirement.test.ts | 83 -- .../pty-retirement-async-durability.spec.ts | 112 +++ .../pty-retirement-async-durability.test.ts | 119 --- .../workspace-session-write-listeners.spec.ts | 185 ++++ .../workspace-session-write-listeners.test.ts | 190 ---- .../ContextualTourControl.test.ts | 21 +- ...ontextual-tour-overlay-measurement.test.ts | 21 +- .../editor/editor-dropped-file-open.test.ts | 21 +- .../editor/useMarkdownDocuments.test.ts | 21 +- .../src/components/pet/PetOverlay.test.ts | 21 +- .../pet/pet-overlay-position.test.ts | 21 +- .../use-checks-panel-terminal-worktree.ts | 2 +- .../use-source-control-ai.test.ts | 21 +- .../right-sidebar/useFileExplorerKeys.test.ts | 19 + .../settings/KagiSessionLinkForm.test.ts | 21 +- .../PluginKeybindingConsentPreview.test.ts | 21 +- .../settings/QuickCommandsPane.test.ts | 21 +- .../settings-setup-guide-progress.test.ts | 21 +- .../WorktreeParentPickerPopover.test.ts | 19 + ...lder-workspace-linked-startup-plan.test.ts | 2 +- .../sidebar/hovered-workspace-delete.test.ts | 21 +- .../use-worktree-activity-statuses.test.ts | 21 +- .../tab-bar-item-model.git-status.test.ts | 21 +- .../task-page-gitlab-task-filters.test.ts | 21 +- .../task-page-repo-source-context.test.ts | 21 +- ...page-task-source-host-availability.test.ts | 21 +- .../components/terminal-cold-activation.ts | 2 +- .../parked-terminal-byte-watcher.ts | 2 +- .../remote-execution-host-pty.ts | 2 +- .../terminal-pane/resolve-split-cwd.ts | 2 +- .../terminal-hidden-view-parking.ts | 2 +- .../terminal-hidden-worktree-retention.ts | 2 +- .../terminal-keyboard-search-dispatch.test.ts | 21 +- .../terminal-live-layout-reconciliation.ts | 2 +- .../terminal-parked-pty-watcher.ts | 2 +- ...e-manual-terminal-worktree-parking.test.ts | 21 +- .../use-terminal-pane-layout-persistence.ts | 2 +- .../src/lib/automation-session-observer.ts | 2 +- 140 files changed, 12622 insertions(+), 11993 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/command-receipt-schema.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/command-receipt-schema.test.ts create mode 100644 src/main/native-chat/agent-session-journal/command-receipt-table.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/command-receipt-table.test.ts create mode 100644 src/main/native-chat/agent-session-journal/command-receipt-transaction.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/command-receipt-transaction.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-crash-boundary.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-database-contracts.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-database.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-database.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-epoch-replacement.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-host-database.spec.ts delete 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-item-provenance.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-pi-context-import.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-pi-context-import.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-producer-inheritance.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-recovery-contracts.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-restart-reconciliation.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-row-context-usage.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-row-schema-version.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-row-writer.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-row-writer.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-settlement-contracts.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-store-schema.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-store-schema.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-store.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-store.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-subagent-item.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-subagent-item.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-subagent-liveness.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-positions.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-submission-queued-link.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-unknown-content.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-unknown-row-kind.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-unsent-send-hold.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts create mode 100644 src/main/native-chat/agent-session-journal/journal-write-queue.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/journal-write-queue.test.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-pause.test.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-store.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-store.test.ts create mode 100644 src/main/native-chat/agent-session-journal/queued-message-text-update.spec.ts delete mode 100644 src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts create mode 100644 src/main/persistence/loading-store/durable-settings-write.spec.ts delete mode 100644 src/main/persistence/loading-store/durable-settings-write.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-caller-cancellation.spec.ts delete mode 100644 src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-durable-writes.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-contracts.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-final-failure.spec.ts delete mode 100644 src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance-recovery.spec.ts delete mode 100644 src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-maintenance.spec.ts delete mode 100644 src/main/persistence/loading-store/profile-state-maintenance.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-pty-retirement-finalization.spec.ts delete mode 100644 src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-worker-coordination.spec.ts delete mode 100644 src/main/persistence/loading-store/profile-state-worker-coordination.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-worker-secret-retention.spec.ts delete mode 100644 src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts create mode 100644 src/main/persistence/loading-store/profile-state-write-batching.spec.ts delete mode 100644 src/main/persistence/loading-store/profile-state-write-batching.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-async-durability.spec.ts delete mode 100644 src/main/persistence/loading-store/pty-binding-async-durability.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-created-surface-rollback.spec.ts delete mode 100644 src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts create mode 100644 src/main/persistence/loading-store/pty-binding-restart-retirement.spec.ts delete mode 100644 src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts create mode 100644 src/main/persistence/loading-store/pty-retirement-async-durability.spec.ts delete mode 100644 src/main/persistence/loading-store/pty-retirement-async-durability.test.ts create mode 100644 src/main/persistence/loading-store/workspace-session-write-listeners.spec.ts delete mode 100644 src/main/persistence/loading-store/workspace-session-write-listeners.test.ts diff --git a/config/scripts/oxc-cli-invocation.test.mjs b/config/scripts/oxc-cli-invocation.test.mjs index a5dae584f5f..19d00d33a92 100644 --- a/config/scripts/oxc-cli-invocation.test.mjs +++ b/config/scripts/oxc-cli-invocation.test.mjs @@ -30,6 +30,7 @@ for (const { name, resolveInvocation } of [ windowsHide: true }) expect(result.error).toBeUndefined() + expect(result.status).toBe(0) expect(result.stdout).toContain(name) }) }) diff --git a/src/main/acp/acp-structured-broken-stream.test.ts b/src/main/acp/acp-structured-broken-stream.test.ts index 5b2d6bf1e27..96ab98eda2f 100644 --- a/src/main/acp/acp-structured-broken-stream.test.ts +++ b/src/main/acp/acp-structured-broken-stream.test.ts @@ -80,6 +80,7 @@ describe('ACP agent that closes its stdout but keeps running', () => { const pidFile = join(mkdtempSync(join(tmpdir(), 'orca-acp-broken-stream-')), 'pid') const rig = await openAcpAdapterRig({ deps: { + stopGraceMs: 20, connect: (_launch, options) => createAcpAgentConnection( { diff --git a/src/main/codex/codex-app-server-teardown.integration.test.ts b/src/main/codex/codex-app-server-teardown.integration.test.ts index 81fb82c7d5c..5932c5cc0dc 100644 --- a/src/main/codex/codex-app-server-teardown.integration.test.ts +++ b/src/main/codex/codex-app-server-teardown.integration.test.ts @@ -107,9 +107,10 @@ describe.runIf(process.platform !== 'win32')('Codex app-server process teardown' } }, 30_000) - it.concurrent.each(['normal', 'signal'] as const)( + it.concurrent.for(['normal', 'signal'] as const)( 'reaps provider descendants before relaying a %s root exit in 40 consecutive launches', - async (exitMode) => { + { timeout: 60_000 }, + async (exitMode, { expect }) => { for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { const server = await openServer(iteration, exitMode) try { @@ -121,11 +122,12 @@ describe.runIf(process.platform !== 'win32')('Codex app-server process teardown' await cleanupServer(server) } } - }, - 60_000 + } ) - it('does not settle a stdin-close/root-exit race before the descendant is reaped', async () => { + it.concurrent('does not settle a stdin-close/root-exit race before the descendant is reaped', async ({ + expect + }) => { for (let iteration = 0; iteration < ITERATIONS; iteration += 1) { const server = await openServer(iteration, 'stdin-race') try { diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.spec.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.spec.ts new file mode 100644 index 00000000000..eaac07007b5 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.spec.ts @@ -0,0 +1,178 @@ +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 } from 'vitest' +import Database from '../../sqlite/sync-database' +import { journalPragmaNumber, openJournalDatabase, runJournalTransaction } from './journal-database' +import { + createAgentSessionRecordTablesSql, + createJournalTablesSql, + JOURNAL_DB_SCHEMA_VERSION +} from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table' +import { commandReceiptScope } from './command-receipt-schema' +import { + commandReceiptFixture, + writeCommandReceiptTestRecord +} from './command-receipt-test-support' +import { AgentSessionJournalError } from './journal-write-guards' +import { buildCommandReceiptTransaction } from './command-receipt-transaction' + +export function registerCommandReceiptSchemaTests(): void { + let root: string + let dbPath: string + const scope = commandReceiptScope('caller-1', 'global') + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-command-receipt-schema-')) + dbPath = journalDatabasePath(root) + }) + + afterEach(async () => { + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) + }) + + function hasReceiptTable(db: Database.Database): boolean { + return Boolean( + db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('agent_session_command_receipts') + ) + } + + describe('command receipt schema at writable open', () => { + it('creates the operation/caller primary key and session index on a fresh host database', () => { + const database = openTestJournalHostDatabase(root) + expect(hasReceiptTable(database.db)).toBe(true) + expect(journalPragmaNumber(database.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) + expect( + database.db.prepare('PRAGMA table_info(agent_session_command_receipts)').all() + ).toEqual([ + expect.objectContaining({ name: 'operation_id', pk: 1 }), + expect.objectContaining({ name: 'session_id', pk: 0 }), + expect.objectContaining({ name: 'caller_key', pk: 2 }), + expect.objectContaining({ name: 'method', pk: 0 }), + expect.objectContaining({ name: 'fingerprint', pk: 0 }), + expect.objectContaining({ name: 'status', pk: 0 }), + expect.objectContaining({ name: 'result_json', pk: 0 }), + expect.objectContaining({ name: 'rejection_json', pk: 0 }), + expect.objectContaining({ name: 'accepted_at', pk: 0 }) + ]) + const indexes = database.db.prepare('PRAGMA index_list(agent_session_command_receipts)').all() + expect(indexes).toHaveLength(2) + expect(indexes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'sqlite_autoindex_agent_session_command_receipts_1', + origin: 'pk' + }), + expect.objectContaining({ name: 'agent_session_command_receipts_session', origin: 'c' }) + ]) + ) + expect( + database.db.prepare('PRAGMA index_info(agent_session_command_receipts_session)').all() + ).toEqual([expect.objectContaining({ name: 'session_id' })]) + expect( + database.db.prepare('PRAGMA foreign_key_list(agent_session_command_receipts)').all() + ).toEqual([ + expect.objectContaining({ + table: 'agent_session_records', + from: 'session_id', + to: 'session_id', + on_delete: 'CASCADE' + }) + ]) + }) + + it.each([3, 4])('adds the table to an older-shaped version %i database', (version) => { + const seeded = new Database(dbPath) + seeded.exec(createJournalTablesSql()) + if (version === 4) { + seeded.exec(createAgentSessionRecordTablesSql()) + writeCommandReceiptTestRecord(seeded) + } + seeded.pragma(`user_version = ${version}`) + expect(hasReceiptTable(seeded)).toBe(false) + seeded.close() + + for (let open = 0; open < 2; open += 1) { + const { db, readOnly } = openJournalDatabase(dbPath) + try { + expect(readOnly).toBe(false) + expect(hasReceiptTable(db)).toBe(true) + expect(journalPragmaNumber(db, 'user_version')).toBe(4) + if (version === 4) { + expect(db.prepare('SELECT session_id FROM agent_session_records').all()).toEqual([ + { session_id: 'session-1' } + ]) + } + } finally { + db.close() + } + } + }) + }) + + describe('command receipts on a newer database', () => { + it('reads receipts, refuses writes with the journal guard and leaves the file byte-identical', async () => { + const seeded = openJournalDatabase(dbPath).db + const receipt = commandReceiptFixture() + runJournalTransaction(seeded, (db) => { + writeCommandReceiptTestRecord(db) + insertCommandReceiptIfAbsent(db, scope, receipt) + }) + seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + seeded.close() + const before = await readFile(dbPath) + + const { db, readOnly } = openJournalDatabase(dbPath) + try { + expect(readOnly).toBe(true) + expect(readCommandReceipt(db, scope, receipt.operationId)).toEqual({ + verdict: 'readable', + receipt + }) + const write = () => insertCommandReceiptIfAbsent(db, scope, receipt) + expect(write).toThrow(AgentSessionJournalError) + expect(write).toThrow(expect.objectContaining({ code: 'journal_read_only' })) + expect(() => buildCommandReceiptTransaction(scope, receipt).write(db)).toThrow( + expect.objectContaining({ code: 'journal_read_only' }) + ) + } finally { + db.close() + } + + expect(await readFile(dbPath)).toEqual(before) + expect((await stat(`${dbPath}-wal`).catch(() => null))?.size ?? 0).toBe(0) + }) + + it('does not create a missing table or mistake unavailable proof for an absent command', async () => { + const seeded = new Database(dbPath) + seeded.exec(createJournalTablesSql()) + seeded.exec(createAgentSessionRecordTablesSql()) + seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + seeded.close() + const before = await readFile(dbPath) + + const { db } = openJournalDatabase(dbPath) + try { + const receipt = commandReceiptFixture() + expect(hasReceiptTable(db)).toBe(false) + expect(readCommandReceipt(db, scope, receipt.operationId)).toEqual({ + verdict: 'unreadable', + scope, + operationId: receipt.operationId + }) + } finally { + db.close() + } + expect(await readFile(dbPath)).toEqual(before) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.test.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.test.ts deleted file mode 100644 index 6c5fbcfa452..00000000000 --- a/src/main/native-chat/agent-session-journal/command-receipt-schema.test.ts +++ /dev/null @@ -1,174 +0,0 @@ -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 } from 'vitest' -import Database from '../../sqlite/sync-database' -import { journalPragmaNumber, openJournalDatabase, runJournalTransaction } from './journal-database' -import { - createAgentSessionRecordTablesSql, - createJournalTablesSql, - JOURNAL_DB_SCHEMA_VERSION -} from './journal-database-schema' -import { journalDatabasePath } from './journal-host-database' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table' -import { commandReceiptScope } from './command-receipt-schema' -import { - commandReceiptFixture, - writeCommandReceiptTestRecord -} from './command-receipt-test-support' -import { AgentSessionJournalError } from './journal-write-guards' -import { buildCommandReceiptTransaction } from './command-receipt-transaction' - -let root: string -let dbPath: string -const scope = commandReceiptScope('caller-1', 'global') - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-command-receipt-schema-')) - dbPath = journalDatabasePath(root) -}) - -afterEach(async () => { - closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -function hasReceiptTable(db: Database.Database): boolean { - return Boolean( - db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") - .get('agent_session_command_receipts') - ) -} - -describe('command receipt schema at writable open', () => { - it('creates the operation/caller primary key and session index on a fresh host database', () => { - const database = openTestJournalHostDatabase(root) - expect(hasReceiptTable(database.db)).toBe(true) - expect(journalPragmaNumber(database.db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) - expect(database.db.prepare('PRAGMA table_info(agent_session_command_receipts)').all()).toEqual([ - expect.objectContaining({ name: 'operation_id', pk: 1 }), - expect.objectContaining({ name: 'session_id', pk: 0 }), - expect.objectContaining({ name: 'caller_key', pk: 2 }), - expect.objectContaining({ name: 'method', pk: 0 }), - expect.objectContaining({ name: 'fingerprint', pk: 0 }), - expect.objectContaining({ name: 'status', pk: 0 }), - expect.objectContaining({ name: 'result_json', pk: 0 }), - expect.objectContaining({ name: 'rejection_json', pk: 0 }), - expect.objectContaining({ name: 'accepted_at', pk: 0 }) - ]) - const indexes = database.db.prepare('PRAGMA index_list(agent_session_command_receipts)').all() - expect(indexes).toHaveLength(2) - expect(indexes).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - name: 'sqlite_autoindex_agent_session_command_receipts_1', - origin: 'pk' - }), - expect.objectContaining({ name: 'agent_session_command_receipts_session', origin: 'c' }) - ]) - ) - expect( - database.db.prepare('PRAGMA index_info(agent_session_command_receipts_session)').all() - ).toEqual([expect.objectContaining({ name: 'session_id' })]) - expect( - database.db.prepare('PRAGMA foreign_key_list(agent_session_command_receipts)').all() - ).toEqual([ - expect.objectContaining({ - table: 'agent_session_records', - from: 'session_id', - to: 'session_id', - on_delete: 'CASCADE' - }) - ]) - }) - - it.each([3, 4])('adds the table to an older-shaped version %i database', (version) => { - const seeded = new Database(dbPath) - seeded.exec(createJournalTablesSql()) - if (version === 4) { - seeded.exec(createAgentSessionRecordTablesSql()) - writeCommandReceiptTestRecord(seeded) - } - seeded.pragma(`user_version = ${version}`) - expect(hasReceiptTable(seeded)).toBe(false) - seeded.close() - - for (let open = 0; open < 2; open += 1) { - const { db, readOnly } = openJournalDatabase(dbPath) - try { - expect(readOnly).toBe(false) - expect(hasReceiptTable(db)).toBe(true) - expect(journalPragmaNumber(db, 'user_version')).toBe(4) - if (version === 4) { - expect(db.prepare('SELECT session_id FROM agent_session_records').all()).toEqual([ - { session_id: 'session-1' } - ]) - } - } finally { - db.close() - } - } - }) -}) - -describe('command receipts on a newer database', () => { - it('reads receipts, refuses writes with the journal guard and leaves the file byte-identical', async () => { - const seeded = openJournalDatabase(dbPath).db - const receipt = commandReceiptFixture() - runJournalTransaction(seeded, (db) => { - writeCommandReceiptTestRecord(db) - insertCommandReceiptIfAbsent(db, scope, receipt) - }) - seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - seeded.close() - const before = await readFile(dbPath) - - const { db, readOnly } = openJournalDatabase(dbPath) - try { - expect(readOnly).toBe(true) - expect(readCommandReceipt(db, scope, receipt.operationId)).toEqual({ - verdict: 'readable', - receipt - }) - const write = () => insertCommandReceiptIfAbsent(db, scope, receipt) - expect(write).toThrow(AgentSessionJournalError) - expect(write).toThrow(expect.objectContaining({ code: 'journal_read_only' })) - expect(() => buildCommandReceiptTransaction(scope, receipt).write(db)).toThrow( - expect.objectContaining({ code: 'journal_read_only' }) - ) - } finally { - db.close() - } - - expect(await readFile(dbPath)).toEqual(before) - expect((await stat(`${dbPath}-wal`).catch(() => null))?.size ?? 0).toBe(0) - }) - - it('does not create a missing table or mistake unavailable proof for an absent command', async () => { - const seeded = new Database(dbPath) - seeded.exec(createJournalTablesSql()) - seeded.exec(createAgentSessionRecordTablesSql()) - seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - seeded.close() - const before = await readFile(dbPath) - - const { db } = openJournalDatabase(dbPath) - try { - const receipt = commandReceiptFixture() - expect(hasReceiptTable(db)).toBe(false) - expect(readCommandReceipt(db, scope, receipt.operationId)).toEqual({ - verdict: 'unreadable', - scope, - operationId: receipt.operationId - }) - } finally { - db.close() - } - expect(await readFile(dbPath)).toEqual(before) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/command-receipt-table.spec.ts b/src/main/native-chat/agent-session-journal/command-receipt-table.spec.ts new file mode 100644 index 00000000000..b3f16bf767e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-table.spec.ts @@ -0,0 +1,364 @@ +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 { JournalHostDatabase } from './journal-host-database' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { + commandReceiptScope, + type CommandReceipt, + type CommandReceiptScope +} from './command-receipt-schema' +import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table' +import { + commandReceiptFixture, + writeCommandReceiptTestRecord +} from './command-receipt-test-support' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' + +export function registerCommandReceiptTableTests(): void { + let root: string + let database: JournalHostDatabase + const globalScope = commandReceiptScope('caller-1', 'global') + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-command-receipt-table-')) + database = openTestJournalHostDatabase(root) + database.transaction((db) => writeCommandReceiptTestRecord(db)) + }) + + afterEach(async () => { + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) + }) + + function insert(receipt: CommandReceipt, scope: CommandReceiptScope = globalScope) { + return database.transaction((db) => insertCommandReceiptIfAbsent(db, scope, receipt)) + } + + function read(receipt: CommandReceipt, scope: CommandReceiptScope = globalScope) { + return readCommandReceipt(database.db, scope, receipt.operationId) + } + + function cancelReceipt(callerKey: string, turnId = 'turn-1') { + return commandReceiptFixture({ + callerKey, + method: 'agentSession.cancel', + fingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.cancel', + sessionId: 'session-1', + fields: { turnId } + }), + result: { kind: 'no-op', outcome: { kind: 'cancel', cancelled: false, turnId } } + }) + } + + describe('command receipt identity', () => { + it('inserts a send once and replays the original receipt across callers with the same fingerprint', () => { + const receipt = commandReceiptFixture() + expect(read(receipt)).toEqual({ verdict: 'absent' }) + expect(insert(receipt)).toEqual({ inserted: true }) + expect(insert({ ...receipt, acceptedAt: 2000, callerKey: 'caller-2' })).toEqual({ + inserted: false, + reason: 'duplicate', + existing: { verdict: 'readable', receipt } + }) + expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) + }) + + it('reports a changed canonical hash, including a different method or chat, as conflict', () => { + const receipt = commandReceiptFixture() + insert(receipt) + database.transaction((db) => writeCommandReceiptTestRecord(db, 'session-2')) + for (const target of [ + { method: 'agentSession.cancel', sessionId: 'session-1' }, + { method: 'agentSession.send', sessionId: 'session-2' } + ]) { + const changed = commandReceiptFixture({ + ...target, + fingerprint: computeAgentSessionPayloadFingerprint({ ...target, fields: {} }) + }) + expect(insert(changed)).toEqual({ + inserted: false, + reason: 'conflict', + existing: { verdict: 'readable', receipt } + }) + } + expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) + }) + + it('conflicts when the same caller sends and then stops with the same id', () => { + const send = commandReceiptFixture() + const stop = cancelReceipt(send.callerKey) + const scope = commandReceiptScope(stop.callerKey) + expect(insert(send)).toEqual({ inserted: true }) + expect(read(stop, scope)).toEqual({ verdict: 'readable', receipt: send }) + expect(insert(stop, scope)).toEqual({ + inserted: false, + reason: 'conflict', + existing: { verdict: 'readable', receipt: send } + }) + }) + + it('conflicts when one caller stops and another sends with the same id', () => { + const stop = cancelReceipt('caller-1') + expect(insert(stop, commandReceiptScope(stop.callerKey))).toEqual({ inserted: true }) + expect(insert(commandReceiptFixture({ callerKey: 'caller-2' }))).toEqual({ + inserted: false, + reason: 'conflict', + existing: { verdict: 'readable', receipt: stop } + }) + }) + + it('allows separate callers to stop with the same id', () => { + for (const callerKey of ['caller-1', 'caller-2']) { + const receipt = cancelReceipt(callerKey) + const scope = commandReceiptScope(callerKey) + expect(insert(receipt, scope)).toEqual({ inserted: true }) + expect(read(receipt, scope)).toEqual({ verdict: 'readable', receipt }) + } + }) + + it('allows another caller to stop with an id already used by a send', () => { + const send = commandReceiptFixture() + const stop = cancelReceipt('caller-2') + const scope = commandReceiptScope(stop.callerKey) + expect(insert(send)).toEqual({ inserted: true }) + expect(read(stop, scope)).toEqual({ verdict: 'absent' }) + expect(insert(stop, scope)).toEqual({ inserted: true }) + expect(read(stop, scope)).toEqual({ verdict: 'readable', receipt: stop }) + }) + + it('treats a caller key literally named global as an ordinary caller', () => { + const stop = cancelReceipt('global') + const scope = commandReceiptScope(stop.callerKey) + expect(insert(stop, scope)).toEqual({ inserted: true }) + expect(insert(cancelReceipt('caller-2'), commandReceiptScope('caller-2'))).toEqual({ + inserted: true + }) + expect(read(stop, scope)).toEqual({ verdict: 'readable', receipt: stop }) + expect(insert(commandReceiptFixture())).toMatchObject({ inserted: false, reason: 'conflict' }) + }) + + it.each([500, 1000])( + 'orders global candidates by insertion and checks every fingerprint (%i)', + (acceptedAt) => { + const first = cancelReceipt('caller-b', 'turn-b') + const later = { ...cancelReceipt('caller-a', 'turn-a'), acceptedAt } + expect(insert(first, commandReceiptScope(first.callerKey))).toEqual({ inserted: true }) + expect(insert(later, commandReceiptScope(later.callerKey))).toEqual({ inserted: true }) + expect(read(later)).toEqual({ verdict: 'readable', receipt: first }) + expect(insert({ ...later, callerKey: 'caller-c' })).toEqual({ + inserted: false, + reason: 'duplicate', + existing: { verdict: 'readable', receipt: later } + }) + expect(insert(commandReceiptFixture())).toEqual({ + inserted: false, + reason: 'conflict', + existing: { verdict: 'readable', receipt: first } + }) + } + ) + + it('refuses a caller that does not match its namespace', () => { + const receipt = commandReceiptFixture() + expect(() => insert(receipt, commandReceiptScope('caller-2'))).toThrow( + 'command caller does not match its scope' + ) + expect(read(receipt)).toEqual({ verdict: 'absent' }) + }) + + it('requires the chat record to exist and does not leave an orphan receipt', () => { + const receipt = commandReceiptFixture({ sessionId: 'missing-chat' }) + expect(() => insert(receipt)).toThrow(/FOREIGN KEY/i) + expect(read(receipt)).toEqual({ verdict: 'absent' }) + }) + + it('requires a receipt to be written inside an open transaction', () => { + const receipt = commandReceiptFixture() + expect(() => insertCommandReceiptIfAbsent(database.db, globalScope, receipt)).toThrow( + /inside an open transaction/ + ) + expect(read(receipt)).toEqual({ verdict: 'absent' }) + }) + + it('throws and rolls back an unexpected key collision after an absent lookup', () => { + database.db + .exec(`CREATE TRIGGER collide_command_receipt BEFORE INSERT ON agent_session_command_receipts + BEGIN + INSERT INTO agent_session_command_receipts + (operation_id, session_id, caller_key, method, fingerprint, status, result_json, rejection_json, accepted_at) + VALUES (NEW.operation_id, NEW.session_id, NEW.caller_key, NEW.method, NEW.fingerprint, + NEW.status, NEW.result_json, NEW.rejection_json, NEW.accepted_at); + END`) + const receipt = commandReceiptFixture() + expect(() => insert(receipt)).toThrow(/UNIQUE constraint/i) + expect(read(receipt)).toEqual({ verdict: 'absent' }) + expect(database.db.isTransaction).toBe(false) + }) + }) + + describe('command receipt outcomes and lifetime', () => { + it('round trips a queued-draft pointer without retaining its body', () => { + const receipt = commandReceiptFixture({ + result: { kind: 'queued-draft', messageId: 'draft-1' } + }) + insert(receipt) + expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) + }) + + it.each([ + { kind: 'cancel', cancelled: false, turnId: 'turn-1' }, + { kind: 'cancel', cancelled: false }, + { kind: 'queue-resume', resumed: false } + ] as const)('reads the small no-op outcome $kind', (outcome) => { + const receipt = commandReceiptFixture({ result: { kind: 'no-op', outcome } }) + insert(receipt) + expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) + }) + + it('retains a refused outcome with its code, message and details', () => { + const { result: _result, ...identity } = commandReceiptFixture() + const receipt: CommandReceipt = { + ...identity, + status: 'rejected', + rejection: { + reference: { code: 'agent_session_operation_invalid', details: { reason: 'promptGone' } }, + message: 'The prompt is no longer available.' + } + } + insert(receipt) + expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) + expect(insert(commandReceiptFixture())).toMatchObject({ + inserted: false, + reason: 'duplicate' + }) + }) + + it('preserves receipts across record upserts and cascades only on record deletion', () => { + const receipt = commandReceiptFixture() + insert(receipt) + database.transaction((db) => + writeCommandReceiptTestRecord(db, 'session-1', '{"updated":true}') + ) + expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) + database.transaction((db) => writeCommandReceiptTestRecord(db, 'session-1', null)) + expect(read(receipt)).toEqual({ verdict: 'absent' }) + }) + }) + + describe('unreadable command receipts', () => { + it.each([ + ['result_json', '{'], + ['result_json', '{"kind":"journal-row","epoch":"e1","sequence":0}'], + ['result_json', '{"kind":"future-result"}'], + ['result_json', '{"kind":"queued-draft","messageId":""}'], + ['result_json', null], + ['status', 'pending'], + ['method', ''], + ['fingerprint', ''], + ['accepted_at', -1], + ['caller_key', ''], + ['rejection_json', '{}'] + ])('preserves the key when %s holds malformed data %s', (column, value) => { + const receipt = commandReceiptFixture() + insert(receipt) + database.db.prepare(`UPDATE agent_session_command_receipts SET ${column} = ?`).run(value) + const existing = { + verdict: 'unreadable', + scope: globalScope, + operationId: receipt.operationId + } + expect(read(receipt)).toEqual(existing) + expect(insert(receipt)).toEqual({ inserted: false, reason: 'unreadable', existing }) + }) + + it('treats a malformed rejection as unreadable rather than granting another execution', () => { + const receipt = commandReceiptFixture() + insert(receipt) + database.db + .prepare(`UPDATE agent_session_command_receipts SET status = 'rejected', result_json = NULL, + rejection_json = ? WHERE caller_key = ? AND operation_id = ?`) + .run( + JSON.stringify({ + reference: { code: 'agent_session_operation_invalid', details: { reason: 'madeUp' } } + }), + receipt.callerKey, + receipt.operationId + ) + expect(read(receipt)).toMatchObject({ verdict: 'unreadable' }) + expect(insert(receipt)).toMatchObject({ inserted: false, reason: 'unreadable' }) + }) + + it('refuses a global lookup if any candidate is unreadable, even after a matching receipt', () => { + const readable = cancelReceipt('caller-a') + const unreadable = cancelReceipt('caller-b') + insert(readable, commandReceiptScope(readable.callerKey)) + insert(unreadable, commandReceiptScope(unreadable.callerKey)) + database.db + .prepare("UPDATE agent_session_command_receipts SET result_json = '{' WHERE caller_key = ?") + .run(unreadable.callerKey) + expect(read(readable, commandReceiptScope(readable.callerKey))).toEqual({ + verdict: 'readable', + receipt: readable + }) + const existing = { + verdict: 'unreadable', + scope: globalScope, + operationId: readable.operationId + } + expect(read(readable)).toEqual(existing) + expect(insert({ ...readable, callerKey: 'caller-c' })).toEqual({ + inserted: false, + reason: 'unreadable', + existing + }) + }) + + it('reads an oversized accepted_at integer as unreadable', () => { + const receipt = commandReceiptFixture() + insert(receipt) + database.db.exec('UPDATE agent_session_command_receipts SET accepted_at = 9007199254740993') + expect(read(receipt)).toEqual({ + verdict: 'unreadable', + scope: globalScope, + operationId: receipt.operationId + }) + expect(insert(receipt)).toMatchObject({ inserted: false, reason: 'unreadable' }) + }) + + it('reads a table missing an expected column as unreadable', () => { + database.db.exec( + 'ALTER TABLE agent_session_command_receipts RENAME COLUMN result_json TO future_result_json' + ) + const receipt = commandReceiptFixture() + for (const scope of [globalScope, commandReceiptScope(receipt.callerKey)]) { + expect(read(receipt, scope)).toEqual({ + verdict: 'unreadable', + scope, + operationId: receipt.operationId + }) + expect(insert(receipt, scope)).toMatchObject({ inserted: false, reason: 'unreadable' }) + } + }) + + it.each([ + [5, 'database is locked'], + [10, 'disk I/O error'] + ] as const)('propagates operational SQLite error %i', (errcode, message) => { + const failure = Object.assign(new Error(message), { code: 'ERR_SQLITE_ERROR', errcode }) + const prepare = vi.spyOn(database.db, 'prepare').mockImplementationOnce(() => { + throw failure + }) + try { + expect(() => read(commandReceiptFixture())).toThrow(failure) + } finally { + prepare.mockRestore() + } + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/command-receipt-table.test.ts b/src/main/native-chat/agent-session-journal/command-receipt-table.test.ts deleted file mode 100644 index 4f0da005da7..00000000000 --- a/src/main/native-chat/agent-session-journal/command-receipt-table.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -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 { JournalHostDatabase } from './journal-host-database' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import { - commandReceiptScope, - type CommandReceipt, - type CommandReceiptScope -} from './command-receipt-schema' -import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table' -import { - commandReceiptFixture, - writeCommandReceiptTestRecord -} from './command-receipt-test-support' -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' - -let root: string -let database: JournalHostDatabase -const globalScope = commandReceiptScope('caller-1', 'global') - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-command-receipt-table-')) - database = openTestJournalHostDatabase(root) - database.transaction((db) => writeCommandReceiptTestRecord(db)) -}) - -afterEach(async () => { - closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -function insert(receipt: CommandReceipt, scope: CommandReceiptScope = globalScope) { - return database.transaction((db) => insertCommandReceiptIfAbsent(db, scope, receipt)) -} - -function read(receipt: CommandReceipt, scope: CommandReceiptScope = globalScope) { - return readCommandReceipt(database.db, scope, receipt.operationId) -} - -function cancelReceipt(callerKey: string, turnId = 'turn-1') { - return commandReceiptFixture({ - callerKey, - method: 'agentSession.cancel', - fingerprint: computeAgentSessionPayloadFingerprint({ - method: 'agentSession.cancel', - sessionId: 'session-1', - fields: { turnId } - }), - result: { kind: 'no-op', outcome: { kind: 'cancel', cancelled: false, turnId } } - }) -} - -describe('command receipt identity', () => { - it('inserts a send once and replays the original receipt across callers with the same fingerprint', () => { - const receipt = commandReceiptFixture() - expect(read(receipt)).toEqual({ verdict: 'absent' }) - expect(insert(receipt)).toEqual({ inserted: true }) - expect(insert({ ...receipt, acceptedAt: 2000, callerKey: 'caller-2' })).toEqual({ - inserted: false, - reason: 'duplicate', - existing: { verdict: 'readable', receipt } - }) - expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) - }) - - it('reports a changed canonical hash, including a different method or chat, as conflict', () => { - const receipt = commandReceiptFixture() - insert(receipt) - database.transaction((db) => writeCommandReceiptTestRecord(db, 'session-2')) - for (const target of [ - { method: 'agentSession.cancel', sessionId: 'session-1' }, - { method: 'agentSession.send', sessionId: 'session-2' } - ]) { - const changed = commandReceiptFixture({ - ...target, - fingerprint: computeAgentSessionPayloadFingerprint({ ...target, fields: {} }) - }) - expect(insert(changed)).toEqual({ - inserted: false, - reason: 'conflict', - existing: { verdict: 'readable', receipt } - }) - } - expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) - }) - - it('conflicts when the same caller sends and then stops with the same id', () => { - const send = commandReceiptFixture() - const stop = cancelReceipt(send.callerKey) - const scope = commandReceiptScope(stop.callerKey) - expect(insert(send)).toEqual({ inserted: true }) - expect(read(stop, scope)).toEqual({ verdict: 'readable', receipt: send }) - expect(insert(stop, scope)).toEqual({ - inserted: false, - reason: 'conflict', - existing: { verdict: 'readable', receipt: send } - }) - }) - - it('conflicts when one caller stops and another sends with the same id', () => { - const stop = cancelReceipt('caller-1') - expect(insert(stop, commandReceiptScope(stop.callerKey))).toEqual({ inserted: true }) - expect(insert(commandReceiptFixture({ callerKey: 'caller-2' }))).toEqual({ - inserted: false, - reason: 'conflict', - existing: { verdict: 'readable', receipt: stop } - }) - }) - - it('allows separate callers to stop with the same id', () => { - for (const callerKey of ['caller-1', 'caller-2']) { - const receipt = cancelReceipt(callerKey) - const scope = commandReceiptScope(callerKey) - expect(insert(receipt, scope)).toEqual({ inserted: true }) - expect(read(receipt, scope)).toEqual({ verdict: 'readable', receipt }) - } - }) - - it('allows another caller to stop with an id already used by a send', () => { - const send = commandReceiptFixture() - const stop = cancelReceipt('caller-2') - const scope = commandReceiptScope(stop.callerKey) - expect(insert(send)).toEqual({ inserted: true }) - expect(read(stop, scope)).toEqual({ verdict: 'absent' }) - expect(insert(stop, scope)).toEqual({ inserted: true }) - expect(read(stop, scope)).toEqual({ verdict: 'readable', receipt: stop }) - }) - - it('treats a caller key literally named global as an ordinary caller', () => { - const stop = cancelReceipt('global') - const scope = commandReceiptScope(stop.callerKey) - expect(insert(stop, scope)).toEqual({ inserted: true }) - expect(insert(cancelReceipt('caller-2'), commandReceiptScope('caller-2'))).toEqual({ - inserted: true - }) - expect(read(stop, scope)).toEqual({ verdict: 'readable', receipt: stop }) - expect(insert(commandReceiptFixture())).toMatchObject({ inserted: false, reason: 'conflict' }) - }) - - it.each([500, 1000])( - 'orders global candidates by insertion and checks every fingerprint (%i)', - (acceptedAt) => { - const first = cancelReceipt('caller-b', 'turn-b') - const later = { ...cancelReceipt('caller-a', 'turn-a'), acceptedAt } - expect(insert(first, commandReceiptScope(first.callerKey))).toEqual({ inserted: true }) - expect(insert(later, commandReceiptScope(later.callerKey))).toEqual({ inserted: true }) - expect(read(later)).toEqual({ verdict: 'readable', receipt: first }) - expect(insert({ ...later, callerKey: 'caller-c' })).toEqual({ - inserted: false, - reason: 'duplicate', - existing: { verdict: 'readable', receipt: later } - }) - expect(insert(commandReceiptFixture())).toEqual({ - inserted: false, - reason: 'conflict', - existing: { verdict: 'readable', receipt: first } - }) - } - ) - - it('refuses a caller that does not match its namespace', () => { - const receipt = commandReceiptFixture() - expect(() => insert(receipt, commandReceiptScope('caller-2'))).toThrow( - 'command caller does not match its scope' - ) - expect(read(receipt)).toEqual({ verdict: 'absent' }) - }) - - it('requires the chat record to exist and does not leave an orphan receipt', () => { - const receipt = commandReceiptFixture({ sessionId: 'missing-chat' }) - expect(() => insert(receipt)).toThrow(/FOREIGN KEY/i) - expect(read(receipt)).toEqual({ verdict: 'absent' }) - }) - - it('requires a receipt to be written inside an open transaction', () => { - const receipt = commandReceiptFixture() - expect(() => insertCommandReceiptIfAbsent(database.db, globalScope, receipt)).toThrow( - /inside an open transaction/ - ) - expect(read(receipt)).toEqual({ verdict: 'absent' }) - }) - - it('throws and rolls back an unexpected key collision after an absent lookup', () => { - database.db - .exec(`CREATE TRIGGER collide_command_receipt BEFORE INSERT ON agent_session_command_receipts - BEGIN - INSERT INTO agent_session_command_receipts - (operation_id, session_id, caller_key, method, fingerprint, status, result_json, rejection_json, accepted_at) - VALUES (NEW.operation_id, NEW.session_id, NEW.caller_key, NEW.method, NEW.fingerprint, - NEW.status, NEW.result_json, NEW.rejection_json, NEW.accepted_at); - END`) - const receipt = commandReceiptFixture() - expect(() => insert(receipt)).toThrow(/UNIQUE constraint/i) - expect(read(receipt)).toEqual({ verdict: 'absent' }) - expect(database.db.isTransaction).toBe(false) - }) -}) - -describe('command receipt outcomes and lifetime', () => { - it('round trips a queued-draft pointer without retaining its body', () => { - const receipt = commandReceiptFixture({ - result: { kind: 'queued-draft', messageId: 'draft-1' } - }) - insert(receipt) - expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) - }) - - it.each([ - { kind: 'cancel', cancelled: false, turnId: 'turn-1' }, - { kind: 'cancel', cancelled: false }, - { kind: 'queue-resume', resumed: false } - ] as const)('reads the small no-op outcome $kind', (outcome) => { - const receipt = commandReceiptFixture({ result: { kind: 'no-op', outcome } }) - insert(receipt) - expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) - }) - - it('retains a refused outcome with its code, message and details', () => { - const { result: _result, ...identity } = commandReceiptFixture() - const receipt: CommandReceipt = { - ...identity, - status: 'rejected', - rejection: { - reference: { code: 'agent_session_operation_invalid', details: { reason: 'promptGone' } }, - message: 'The prompt is no longer available.' - } - } - insert(receipt) - expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) - expect(insert(commandReceiptFixture())).toMatchObject({ inserted: false, reason: 'duplicate' }) - }) - - it('preserves receipts across record upserts and cascades only on record deletion', () => { - const receipt = commandReceiptFixture() - insert(receipt) - database.transaction((db) => writeCommandReceiptTestRecord(db, 'session-1', '{"updated":true}')) - expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) - database.transaction((db) => writeCommandReceiptTestRecord(db, 'session-1', null)) - expect(read(receipt)).toEqual({ verdict: 'absent' }) - }) -}) - -describe('unreadable command receipts', () => { - it.each([ - ['result_json', '{'], - ['result_json', '{"kind":"journal-row","epoch":"e1","sequence":0}'], - ['result_json', '{"kind":"future-result"}'], - ['result_json', '{"kind":"queued-draft","messageId":""}'], - ['result_json', null], - ['status', 'pending'], - ['method', ''], - ['fingerprint', ''], - ['accepted_at', -1], - ['caller_key', ''], - ['rejection_json', '{}'] - ])('preserves the key when %s holds malformed data %s', (column, value) => { - const receipt = commandReceiptFixture() - insert(receipt) - database.db.prepare(`UPDATE agent_session_command_receipts SET ${column} = ?`).run(value) - const existing = { - verdict: 'unreadable', - scope: globalScope, - operationId: receipt.operationId - } - expect(read(receipt)).toEqual(existing) - expect(insert(receipt)).toEqual({ inserted: false, reason: 'unreadable', existing }) - }) - - it('treats a malformed rejection as unreadable rather than granting another execution', () => { - const receipt = commandReceiptFixture() - insert(receipt) - database.db - .prepare(`UPDATE agent_session_command_receipts SET status = 'rejected', result_json = NULL, - rejection_json = ? WHERE caller_key = ? AND operation_id = ?`) - .run( - JSON.stringify({ - reference: { code: 'agent_session_operation_invalid', details: { reason: 'madeUp' } } - }), - receipt.callerKey, - receipt.operationId - ) - expect(read(receipt)).toMatchObject({ verdict: 'unreadable' }) - expect(insert(receipt)).toMatchObject({ inserted: false, reason: 'unreadable' }) - }) - - it('refuses a global lookup if any candidate is unreadable, even after a matching receipt', () => { - const readable = cancelReceipt('caller-a') - const unreadable = cancelReceipt('caller-b') - insert(readable, commandReceiptScope(readable.callerKey)) - insert(unreadable, commandReceiptScope(unreadable.callerKey)) - database.db - .prepare("UPDATE agent_session_command_receipts SET result_json = '{' WHERE caller_key = ?") - .run(unreadable.callerKey) - expect(read(readable, commandReceiptScope(readable.callerKey))).toEqual({ - verdict: 'readable', - receipt: readable - }) - const existing = { - verdict: 'unreadable', - scope: globalScope, - operationId: readable.operationId - } - expect(read(readable)).toEqual(existing) - expect(insert({ ...readable, callerKey: 'caller-c' })).toEqual({ - inserted: false, - reason: 'unreadable', - existing - }) - }) - - it('reads an oversized accepted_at integer as unreadable', () => { - const receipt = commandReceiptFixture() - insert(receipt) - database.db.exec('UPDATE agent_session_command_receipts SET accepted_at = 9007199254740993') - expect(read(receipt)).toEqual({ - verdict: 'unreadable', - scope: globalScope, - operationId: receipt.operationId - }) - expect(insert(receipt)).toMatchObject({ inserted: false, reason: 'unreadable' }) - }) - - it('reads a table missing an expected column as unreadable', () => { - database.db.exec( - 'ALTER TABLE agent_session_command_receipts RENAME COLUMN result_json TO future_result_json' - ) - const receipt = commandReceiptFixture() - for (const scope of [globalScope, commandReceiptScope(receipt.callerKey)]) { - expect(read(receipt, scope)).toEqual({ - verdict: 'unreadable', - scope, - operationId: receipt.operationId - }) - expect(insert(receipt, scope)).toMatchObject({ inserted: false, reason: 'unreadable' }) - } - }) - - it.each([ - [5, 'database is locked'], - [10, 'disk I/O error'] - ] as const)('propagates operational SQLite error %i', (errcode, message) => { - const failure = Object.assign(new Error(message), { code: 'ERR_SQLITE_ERROR', errcode }) - const prepare = vi.spyOn(database.db, 'prepare').mockImplementationOnce(() => { - throw failure - }) - try { - expect(() => read(commandReceiptFixture())).toThrow(failure) - } finally { - prepare.mockRestore() - } - }) -}) diff --git a/src/main/native-chat/agent-session-journal/command-receipt-transaction.spec.ts b/src/main/native-chat/agent-session-journal/command-receipt-transaction.spec.ts new file mode 100644 index 00000000000..c094e4f8dcd --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-transaction.spec.ts @@ -0,0 +1,196 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { JournalHostDatabase } from './journal-host-database' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { replaceJournalEpoch } from './journal-epoch-replacement' +import { runJournalTransaction } from './journal-database' +import { + deleteJournalEpochRows, + insertJournalRow, + publishJournalSessionEpoch +} from './journal-row-table' +import type { JournalRow } from './journal-row-schema' +import { JournalRowWriter } from './journal-row-writer' +import { JournalWriteQueue } from './journal-write-queue' +import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table' +import { commandReceiptScope } from './command-receipt-schema' +import { + commandReceiptFixture, + writeCommandReceiptTestRecord +} from './command-receipt-test-support' +import { + buildCommandReceiptTransaction, + CommandReceiptExistsError +} from './command-receipt-transaction' + +export function registerCommandReceiptTransactionTests(): void { + let root: string + let database: JournalHostDatabase + const receipt = commandReceiptFixture() + const scope = commandReceiptScope(receipt.callerKey, 'global') + const identity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex' as const, + providerHandle: codexProviderHandle('thread-1') + } + + function epochRow(seq: number, ts: number): JournalRow { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: 'epoch-1', + seq, + ts, + fence: 0, + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: 'thread-1' } + } + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-command-receipt-transaction-')) + database = openTestJournalHostDatabase(root) + database.transaction((db) => { + writeCommandReceiptTestRecord(db) + publishJournalSessionEpoch(db, identity, 'epoch-1') + }) + }) + + afterEach(async () => { + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) + }) + + function storedRows() { + return database.db.prepare('SELECT seq FROM journal_rows ORDER BY seq').all() + } + + function readReceipt() { + return readCommandReceipt(database.db, scope, receipt.operationId) + } + + describe('command receipt transaction hook', () => { + it('rolls back the effect and receipt together if the enclosing transaction throws', () => { + const hook = buildCommandReceiptTransaction(scope, receipt) + expect(() => + runJournalTransaction(database.db, (db) => { + insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) + hook.write(db) + throw new Error('effect failed') + }) + ).toThrow('effect failed') + expect(storedRows()).toEqual([]) + expect(readReceipt()).toEqual({ verdict: 'absent' }) + expect(database.db.isTransaction).toBe(false) + }) + + it('commits through the existing row writer with a pointer to the row it assigned', async () => { + const queue = new JournalWriteQueue(identity.sessionId) + const committed: JournalRow[] = [] + const writer = new JournalRowWriter({ + sessionId: identity.sessionId, + now: () => 1000, + serialize: (run) => queue.serialize(run), + database: () => database, + readOnly: () => database.readOnly, + highestFence: () => 0, + nextSequence: () => 7, + commit: (rows) => committed.push(...rows) + }) + await writer.enqueue( + epochRow, + undefined, + buildCommandReceiptTransaction(scope, (row) => { + if (!row) { + throw new Error('expected the committed journal row') + } + return { + ...receipt, + result: { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } + } + }) + ) + expect(committed).toHaveLength(1) + expect(committed[0]).toEqual(epochRow(7, 1000)) + expect(storedRows()).toEqual([{ seq: 7 }]) + expect(readReceipt()).toEqual({ + verdict: 'readable', + receipt: { ...receipt, result: { kind: 'journal-row', epoch: 'epoch-1', sequence: 7 } } + }) + }) + + it.each(['duplicate', 'conflict', 'unreadable'] as const)( + 'throws a typed %s verdict and rolls back the second effect', + (reason) => { + database.transaction((db) => insertCommandReceiptIfAbsent(db, scope, receipt)) + if (reason === 'unreadable') { + database.db.prepare("UPDATE agent_session_command_receipts SET result_json = '{'").run() + } + const hook = buildCommandReceiptTransaction(scope, { + ...receipt, + fingerprint: reason === 'conflict' ? 'changed' : receipt.fingerprint + }) + const write = () => + database.transaction((db) => { + insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) + hook.write(db) + }) + expect(write).toThrow(CommandReceiptExistsError) + expect(write).toThrow( + expect.objectContaining({ result: expect.objectContaining({ reason }) }) + ) + expect(storedRows()).toEqual([]) + expect(readReceipt().verdict).toBe(reason === 'unreadable' ? 'unreadable' : 'readable') + } + ) + }) + + describe('command receipts across epoch replacement', () => { + it('survives deletion of its referenced journal epoch as retained spent proof', () => { + database.transaction((db) => { + insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) + insertCommandReceiptIfAbsent(db, scope, receipt) + deleteJournalEpochRows(db, identity.sessionId, 'epoch-1') + }) + expect(storedRows()).toEqual([]) + expect(readReceipt()).toEqual({ verdict: 'readable', receipt }) + }) + + it('survives the actual replacement transaction without transferring or copying results', () => { + database.transaction((db) => { + insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) + insertCommandReceiptIfAbsent(db, scope, receipt) + }) + replaceJournalEpoch({ + database, + identity, + reason: 'legacy_import', + fence: 0, + items: [], + queuePause: { lifted: false, liveStop: null, reopened: false }, + now: () => 2000, + mintEpoch: () => 'epoch-2', + onPublished: () => undefined + }) + expect(database.db.prepare('SELECT epoch FROM journal_rows').all()).toEqual([ + { epoch: 'epoch-2' } + ]) + expect(readReceipt()).toEqual({ verdict: 'readable', receipt }) + expect( + database.transaction((db) => insertCommandReceiptIfAbsent(db, scope, receipt)) + ).toMatchObject({ + inserted: false, + reason: 'duplicate' + }) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/command-receipt-transaction.test.ts b/src/main/native-chat/agent-session-journal/command-receipt-transaction.test.ts deleted file mode 100644 index 673025d2878..00000000000 --- a/src/main/native-chat/agent-session-journal/command-receipt-transaction.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import type { JournalHostDatabase } from './journal-host-database' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import { replaceJournalEpoch } from './journal-epoch-replacement' -import { runJournalTransaction } from './journal-database' -import { - deleteJournalEpochRows, - insertJournalRow, - publishJournalSessionEpoch -} from './journal-row-table' -import type { JournalRow } from './journal-row-schema' -import { JournalRowWriter } from './journal-row-writer' -import { JournalWriteQueue } from './journal-write-queue' -import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table' -import { commandReceiptScope } from './command-receipt-schema' -import { - commandReceiptFixture, - writeCommandReceiptTestRecord -} from './command-receipt-test-support' -import { - buildCommandReceiptTransaction, - CommandReceiptExistsError -} from './command-receipt-transaction' - -let root: string -let database: JournalHostDatabase -const receipt = commandReceiptFixture() -const scope = commandReceiptScope(receipt.callerKey, 'global') -const identity = { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'host-1', - agent: 'codex' as const, - providerHandle: codexProviderHandle('thread-1') -} - -function epochRow(seq: number, ts: number): JournalRow { - return { - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - epoch: 'epoch-1', - seq, - ts, - fence: 0, - kind: 'epoch', - reason: 'session_created', - providerHandle: { kind: 'codex', threadId: 'thread-1' } - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-command-receipt-transaction-')) - database = openTestJournalHostDatabase(root) - database.transaction((db) => { - writeCommandReceiptTestRecord(db) - publishJournalSessionEpoch(db, identity, 'epoch-1') - }) -}) - -afterEach(async () => { - closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -function storedRows() { - return database.db.prepare('SELECT seq FROM journal_rows ORDER BY seq').all() -} - -function readReceipt() { - return readCommandReceipt(database.db, scope, receipt.operationId) -} - -describe('command receipt transaction hook', () => { - it('rolls back the effect and receipt together if the enclosing transaction throws', () => { - const hook = buildCommandReceiptTransaction(scope, receipt) - expect(() => - runJournalTransaction(database.db, (db) => { - insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) - hook.write(db) - throw new Error('effect failed') - }) - ).toThrow('effect failed') - expect(storedRows()).toEqual([]) - expect(readReceipt()).toEqual({ verdict: 'absent' }) - expect(database.db.isTransaction).toBe(false) - }) - - it('commits through the existing row writer with a pointer to the row it assigned', async () => { - const queue = new JournalWriteQueue(identity.sessionId) - const committed: JournalRow[] = [] - const writer = new JournalRowWriter({ - sessionId: identity.sessionId, - now: () => 1000, - serialize: (run) => queue.serialize(run), - database: () => database, - readOnly: () => database.readOnly, - highestFence: () => 0, - nextSequence: () => 7, - commit: (rows) => committed.push(...rows) - }) - await writer.enqueue( - epochRow, - undefined, - buildCommandReceiptTransaction(scope, (row) => { - if (!row) { - throw new Error('expected the committed journal row') - } - return { ...receipt, result: { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } } - }) - ) - expect(committed).toHaveLength(1) - expect(committed[0]).toEqual(epochRow(7, 1000)) - expect(storedRows()).toEqual([{ seq: 7 }]) - expect(readReceipt()).toEqual({ - verdict: 'readable', - receipt: { ...receipt, result: { kind: 'journal-row', epoch: 'epoch-1', sequence: 7 } } - }) - }) - - it.each(['duplicate', 'conflict', 'unreadable'] as const)( - 'throws a typed %s verdict and rolls back the second effect', - (reason) => { - database.transaction((db) => insertCommandReceiptIfAbsent(db, scope, receipt)) - if (reason === 'unreadable') { - database.db.prepare("UPDATE agent_session_command_receipts SET result_json = '{'").run() - } - const hook = buildCommandReceiptTransaction(scope, { - ...receipt, - fingerprint: reason === 'conflict' ? 'changed' : receipt.fingerprint - }) - const write = () => - database.transaction((db) => { - insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) - hook.write(db) - }) - expect(write).toThrow(CommandReceiptExistsError) - expect(write).toThrow( - expect.objectContaining({ result: expect.objectContaining({ reason }) }) - ) - expect(storedRows()).toEqual([]) - expect(readReceipt().verdict).toBe(reason === 'unreadable' ? 'unreadable' : 'readable') - } - ) -}) - -describe('command receipts across epoch replacement', () => { - it('survives deletion of its referenced journal epoch as retained spent proof', () => { - database.transaction((db) => { - insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) - insertCommandReceiptIfAbsent(db, scope, receipt) - deleteJournalEpochRows(db, identity.sessionId, 'epoch-1') - }) - expect(storedRows()).toEqual([]) - expect(readReceipt()).toEqual({ verdict: 'readable', receipt }) - }) - - it('survives the actual replacement transaction without transferring or copying results', () => { - database.transaction((db) => { - insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) - insertCommandReceiptIfAbsent(db, scope, receipt) - }) - replaceJournalEpoch({ - database, - identity, - reason: 'legacy_import', - fence: 0, - items: [], - queuePause: { lifted: false, liveStop: null, reopened: false }, - now: () => 2000, - mintEpoch: () => 'epoch-2', - onPublished: () => undefined - }) - expect(database.db.prepare('SELECT epoch FROM journal_rows').all()).toEqual([ - { epoch: 'epoch-2' } - ]) - expect(readReceipt()).toEqual({ verdict: 'readable', receipt }) - expect( - database.transaction((db) => insertCommandReceiptIfAbsent(db, scope, receipt)) - ).toMatchObject({ - inserted: false, - reason: 'duplicate' - }) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.spec.ts b/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.spec.ts new file mode 100644 index 00000000000..e54e98275db --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.spec.ts @@ -0,0 +1,421 @@ +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 { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalSnapshot +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' +import { AgentSessionSubscribers } from '../agent-session-wire/structured-agent-session-subscribers' +import { + settleStaleStructuredAgentSessionState, + settleStructuredAgentSessionDeadGeneration +} from '../agent-session-wire/structured-agent-session-dead-generation-settlement' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' +import type { JournalLifecycleMutationInput } from './journal-row-builders' +import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES, parseJournalRow } from './journal-row-schema' +import type { AgentSessionJournal } from './journal-store' + +export function registerJournalAtomicTerminalSettlementTests(): void { + const SESSION = 'atomic-settlement' + const journals = createTrackedJournalOpener() + let root: string + let journal: AgentSessionJournal + + async function open(sessionId = SESSION) { + return journals.open({ + identity: { + sessionId, + workspaceId: 'folder-workspace', + hostId: 'host', + agent: 'codex', + providerHandle: codexProviderHandle('thread') + }, + stateDirectory: root, + now: () => 1_000 + }) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-atomic-settlement-')) + journal = await open() + }) + + afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + function rows() { + return readTestJournalRows(openTestJournalHostDatabase(root).db, SESSION, journal.epoch).map( + (stored) => { + const parsed = parseJournalRow(stored.rowJson) + if (!parsed.ok) { + throw new Error('test row failed to parse') + } + return parsed.row + } + ) + } + + async function seedWork(count = 201) { + await journal.appendSubmission({ + clientMessageId: 'send', + payloadFingerprint: 'fingerprint', + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'continue' }] }, + fence: 7 + }) + const turn = await journal.appendItem( + { provider: 'orca', clientMessageId: 'turn' }, + { kind: 'turn', turnId: 'turn', state: 'running', startedAt: 900 }, + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + for (let index = 0; index < count; index++) { + await journal.appendItem( + { provider: 'orca', clientMessageId: `call-${index}` }, + { + kind: 'tool-call', + name: 'Write', + input: { content: index === 0 ? '界😀'.repeat(220_000) : 'small' }, + state: 'running' + }, + { + fence: 7, + turnScope: { kind: 'turn', turnItemId: turn.itemId }, + agentId: 'child', + producerKind: 'agent' + } + ) + } + } + + function settle() { + return settleStructuredAgentSessionDeadGeneration({ + journal, + sessionId: SESSION, + fence: 7, + settlementId: `exit:${SESSION}:7`, + verdict: { state: 'interrupted', completedAt: 1_000 }, + pendingSubmissionReason: 'provider_exited_before_acknowledgement', + showUnexpectedExitOutcome: true + }) + } + + describe('atomic terminal settlement', () => { + it.each(['item', 'lifecycle-batch'] as const)( + 'rolls back a later %s rejection, publishes nothing, then retries exactly once', + async (kind) => { + await seedWork() + const database = openTestJournalHostDatabase(root) + const before = journal.snapshot() + const diskBefore = rows() + const published: AgentJournalSnapshot[] = [] + const frames: AgentSessionSubscribeEvent[] = [] + const emittedStates: AgentJournalSnapshot[] = [] + const subscribers = new AgentSessionSubscribers() + subscribers.open({ + id: 'reader', + sessionId: SESSION, + journal, + fence: 7, + cursor: before.cursor, + emit: (event) => { + frames.push(event) + emittedStates.push(journal.snapshot()) + } + }) + frames.length = 0 + emittedStates.length = 0 + journal.observeCommits(() => { + published.push(journal.snapshot()) + subscribers.publish(SESSION, journal) + }) + const sawEarlierInserts: number[] = [] + const transaction = database.transaction.bind(database) + vi.spyOn(database, 'transaction').mockImplementation((run) => + transaction((db) => { + try { + const result = run(db) + expect(journal.snapshot()).toEqual(before) + expect(published).toEqual([]) + expect(frames).toEqual([]) + return result + } catch (error) { + sawEarlierInserts.push(rows().length - diskBefore.length) + expect(journal.snapshot()).toEqual(before) + expect(published).toEqual([]) + expect(frames).toEqual([]) + throw error + } + }) + ) + database.db.exec(`CREATE TEMP TRIGGER reject_later_settlement BEFORE INSERT ON journal_rows + WHEN NEW.session_id = '${SESSION}' AND json_extract(NEW.row_json, '$.recovered') = 1 + AND json_extract(NEW.row_json, '$.kind') = '${kind}' + AND NEW.seq > ${before.cursor.sequence + 1} + BEGIN SELECT RAISE(ABORT, 'later settlement row rejected'); END`) + + expect(await settle()).toMatchObject({ ok: false }) + expect(sawEarlierInserts[0]).toBeGreaterThan(0) + expect(rows()).toEqual(diskBefore) + expect(journal.cursor()).toEqual(before.cursor) + expect(journal.snapshot()).toEqual(before) + expect(published).toEqual([]) + expect(frames).toEqual([]) + + vi.restoreAllMocks() + + const other = await open('other-chat') + await expect( + other.appendSubmission({ + clientMessageId: 'unrelated', + payloadFingerprint: 'other', + fence: 0, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + }) + ).resolves.toMatchObject({ sequence: 2 }) + + database.db.exec('DROP TRIGGER reject_later_settlement') + vi.spyOn(database, 'transaction').mockImplementation((run) => + transaction((db) => { + const result = run(db) + expect(journal.snapshot()).toEqual(before) + expect(published).toEqual([]) + expect(frames).toEqual([]) + return result + }) + ) + expect(await settle()).toMatchObject({ ok: true }) + vi.restoreAllMocks() + const settled = journal.snapshot() + expect(published).toEqual([settled]) + expect(frames.length).toBeGreaterThan(0) + expect( + emittedStates.every((state) => state.cursor.sequence === settled.cursor.sequence) + ).toBe(true) + expect( + emittedStates.every((state) => + state.items.every( + (item) => item.body.kind !== 'tool-call' || item.body.state === 'failed' + ) + ) + ).toBe(true) + expect(frames.at(-1)).toMatchObject({ type: 'batch', batch: { cursor: settled.cursor } }) + const frameCount = frames.length + expect(settled.submissions[0]).toMatchObject({ dispatchState: 'unknown', recovered: true }) + expect(journal.runningTurn()?.turnId ?? null).toBeNull() + const calls = settled.items.filter((item) => item.body.kind === 'tool-call') + expect(calls).toHaveLength(201) + expect( + calls.every((item) => item.body.kind === 'tool-call' && item.body.state === 'failed') + ).toBe(true) + expect(calls[0]).toMatchObject({ + observedAt: 1_000, + agentId: 'child', + revision: 2, + body: { endedAs: 'interrupted', input: { content: '界😀'.repeat(220_000) } } + }) + const committed = rows().slice(diskBefore.length) + expect(committed.map((row) => row.kind)).toEqual([ + 'dispatch', + 'lifecycle-batch', + 'item', + 'lifecycle-batch', + 'lifecycle-batch' + ]) + expect(committed.every((row) => row.recovered)).toBe(true) + expect(await settle()).toMatchObject({ ok: true }) + expect(journal.snapshot()).toEqual(settled) + expect(rows().slice(diskBefore.length)).toEqual(committed) + expect(published).toEqual([settled]) + expect(frames).toHaveLength(frameCount) + await journal.close() + journal = await open() + expect(journal.snapshot()).toEqual(settled) + expect(await settle()).toMatchObject({ ok: true }) + expect(journal.snapshot()).toEqual(settled) + } + ) + + it('writes an all-oversized settlement with full bodies, and deduplicates it after replay', async () => { + await seedWork(2) + const calls = journal.snapshot().items.filter((item) => item.body.kind === 'tool-call') + const mutations: JournalLifecycleMutationInput[] = calls.map((item, index) => ({ + kind: 'item', + identity: { provider: 'orca', clientMessageId: `call-${index}` }, + body: { + kind: 'tool-call', + name: 'Write', + state: 'failed', + endedAs: 'interrupted', + input: { content: 'x'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + index) } + }, + turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE + })) + const input = { + settlementId: 'all-oversized:7', + fence: 7, + recovered: true as const, + mutations + } + const before = journal.cursor() + const published: AgentJournalSnapshot[] = [] + journal.observeCommits(() => published.push(journal.snapshot())) + await journal.appendLifecycleBatch(input) + const settled = journal.snapshot() + expect(journal.cursor().sequence).toBe(before.sequence + 2) + expect( + rows() + .slice(-2) + .map((row) => row.kind) + ).toEqual(['item', 'item']) + expect( + settled.items.filter((item) => item.body.kind === 'tool-call').map((item) => item.body) + ).toEqual(mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : []))) + expect(published).toEqual([settled]) + await journal.appendLifecycleBatch(input) + expect(journal.snapshot()).toEqual(settled) + expect(published).toEqual([settled]) + expect( + await journal.appendSteps([ + { + kind: 'settlement', + batch: { + settlementId: input.settlementId, + fence: input.fence, + recovered: true, + resolve: () => mutations + } + } + ]) + ).toEqual([false]) + expect(journal.snapshot()).toEqual(settled) + await journal.close() + journal = await open() + await journal.appendLifecycleBatch(input) + expect(journal.snapshot()).toEqual(settled) + }) + + it('settles stale work through the same oversized fallback without duplicating its outcome', async () => { + await seedWork(1) + const input = { + journal, + sessionId: SESSION, + fence: 8, + acquisitionGeneration: 'new-generation', + deathEvidence: { + kind: 'exit-observed' as const, + ownerFence: 7, + observedAt: 1_000, + detail: '' + } + } + expect(await settleStaleStructuredAgentSessionState(input)).toBeGreaterThan(0) + const settled = journal.snapshot() + expect(await settleStaleStructuredAgentSessionState(input)).toBe(0) + expect(journal.snapshot()).toEqual(settled) + expect(rows().some((row) => row.kind === 'item' && row.recovered)).toBe(true) + }) + + it('uses an ordinary recovered tombstone when the settlement namespace exceeds the batch cap', async () => { + const identity = { provider: 'orca' as const, clientMessageId: 'remove-item' } + await journal.appendItem( + identity, + { kind: 'status', text: 'remove me' }, + { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + const before = journal.cursor() + const input = { + settlementId: 'x'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES), + fence: 7, + recovered: true as const, + mutations: [{ kind: 'tombstone' as const, identity }] + } + await journal.appendLifecycleBatch(input) + expect(journal.snapshot().items).toEqual([]) + expect(journal.cursor().sequence).toBe(before.sequence + 1) + expect(rows().at(-1)).toMatchObject({ kind: 'tombstone', recovered: true }) + await journal.appendLifecycleBatch(input) + expect(journal.cursor().sequence).toBe(before.sequence + 1) + }) + + it.each(['startup', 'stop'] as const)( + 'rolls back the %s submission verdict with its oversized item', + async (mode) => { + await journal.appendSubmission({ + clientMessageId: 'unanswered', + payloadFingerprint: 'unanswered', + fence: 7, + handoverRecorded: true, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] } + }) + await journal.resolveDispatch({ + clientMessageId: 'unanswered', + state: 'pending', + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + if (mode === 'stop') { + await journal.appendStopEvent({ reason: 'user-stop' }, 7) + } + await journal.appendItem( + { provider: 'orca', clientMessageId: 'oversized-call' }, + { + kind: 'tool-call', + name: 'Write', + input: 'x'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES), + state: 'running' + }, + { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const input = { + journal, + sessionId: SESSION, + fence: 7, + settlementId: `${mode}:7`, + verdict: { state: 'interrupted' as const, completedAt: 1_000 }, + pendingSubmissionReason: 'owner exited', + showUnexpectedExitOutcome: mode === 'startup', + ...(mode === 'startup' ? { exitedDuringStartup: { generation: 'start-7' } } : {}) + } + const database = openTestJournalHostDatabase(root) + const before = journal.snapshot() + const diskBefore = rows() + const published: AgentJournalSnapshot[] = [] + journal.observeCommits(() => published.push(journal.snapshot())) + database.db.exec(`CREATE TEMP TRIGGER reject_submission_settlement_item + BEFORE INSERT ON journal_rows + WHEN NEW.session_id = '${SESSION}' AND json_extract(NEW.row_json, '$.recovered') = 1 + AND json_extract(NEW.row_json, '$.kind') = 'item' + BEGIN SELECT RAISE(ABORT, 'item rejected after submission'); END`) + expect(await settleStructuredAgentSessionDeadGeneration(input)).toMatchObject({ ok: false }) + expect(journal.snapshot()).toEqual(before) + expect(rows()).toEqual(diskBefore) + expect(published).toEqual([]) + database.db.exec('DROP TRIGGER reject_submission_settlement_item') + expect(await settleStructuredAgentSessionDeadGeneration(input)).toEqual({ ok: true }) + expect(journal.submission('unanswered')).toMatchObject({ + dispatchState: 'rejected', + recovered: true, + rejection: { kind: mode === 'stop' ? 'cancelled' : 'providerStartFailed' } + }) + const committed = rows().slice(diskBefore.length) + expect(committed.filter((row) => row.kind === 'dispatch')).toHaveLength(1) + expect(published).toEqual([journal.snapshot()]) + expect(await settleStructuredAgentSessionDeadGeneration(input)).toEqual({ ok: true }) + expect(rows().slice(diskBefore.length)).toEqual(committed) + expect(published).toHaveLength(1) + } + ) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts b/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts deleted file mode 100644 index 7fa7e873d4b..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-atomic-terminal-settlement.test.ts +++ /dev/null @@ -1,414 +0,0 @@ -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 { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalSnapshot -} from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import type { AgentSessionSubscribeEvent } from '../../../shared/agent-session-wire' -import { AgentSessionSubscribers } from '../agent-session-wire/structured-agent-session-subscribers' -import { - settleStaleStructuredAgentSessionState, - settleStructuredAgentSessionDeadGeneration -} from '../agent-session-wire/structured-agent-session-dead-generation-settlement' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - readTestJournalRows -} from './journal-host-database-test-support' -import type { JournalLifecycleMutationInput } from './journal-row-builders' -import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES, parseJournalRow } from './journal-row-schema' -import type { AgentSessionJournal } from './journal-store' - -const SESSION = 'atomic-settlement' -const journals = createTrackedJournalOpener() -let root: string -let journal: AgentSessionJournal - -async function open(sessionId = SESSION) { - return journals.open({ - identity: { - sessionId, - workspaceId: 'folder-workspace', - hostId: 'host', - agent: 'codex', - providerHandle: codexProviderHandle('thread') - }, - stateDirectory: root, - now: () => 1_000 - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-atomic-settlement-')) - journal = await open() -}) - -afterEach(async () => { - vi.restoreAllMocks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -function rows() { - return readTestJournalRows(openTestJournalHostDatabase(root).db, SESSION, journal.epoch).map( - (stored) => { - const parsed = parseJournalRow(stored.rowJson) - if (!parsed.ok) { - throw new Error('test row failed to parse') - } - return parsed.row - } - ) -} - -async function seedWork(count = 201) { - await journal.appendSubmission({ - clientMessageId: 'send', - payloadFingerprint: 'fingerprint', - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'continue' }] }, - fence: 7 - }) - const turn = await journal.appendItem( - { provider: 'orca', clientMessageId: 'turn' }, - { kind: 'turn', turnId: 'turn', state: 'running', startedAt: 900 }, - { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - for (let index = 0; index < count; index++) { - await journal.appendItem( - { provider: 'orca', clientMessageId: `call-${index}` }, - { - kind: 'tool-call', - name: 'Write', - input: { content: index === 0 ? '界😀'.repeat(220_000) : 'small' }, - state: 'running' - }, - { - fence: 7, - turnScope: { kind: 'turn', turnItemId: turn.itemId }, - agentId: 'child', - producerKind: 'agent' - } - ) - } -} - -function settle() { - return settleStructuredAgentSessionDeadGeneration({ - journal, - sessionId: SESSION, - fence: 7, - settlementId: `exit:${SESSION}:7`, - verdict: { state: 'interrupted', completedAt: 1_000 }, - pendingSubmissionReason: 'provider_exited_before_acknowledgement', - showUnexpectedExitOutcome: true - }) -} - -describe('atomic terminal settlement', () => { - it.each(['item', 'lifecycle-batch'] as const)( - 'rolls back a later %s rejection, publishes nothing, then retries exactly once', - async (kind) => { - await seedWork() - const database = openTestJournalHostDatabase(root) - const before = journal.snapshot() - const diskBefore = rows() - const published: AgentJournalSnapshot[] = [] - const frames: AgentSessionSubscribeEvent[] = [] - const emittedStates: AgentJournalSnapshot[] = [] - const subscribers = new AgentSessionSubscribers() - subscribers.open({ - id: 'reader', - sessionId: SESSION, - journal, - fence: 7, - cursor: before.cursor, - emit: (event) => { - frames.push(event) - emittedStates.push(journal.snapshot()) - } - }) - frames.length = 0 - emittedStates.length = 0 - journal.observeCommits(() => { - published.push(journal.snapshot()) - subscribers.publish(SESSION, journal) - }) - const sawEarlierInserts: number[] = [] - const transaction = database.transaction.bind(database) - vi.spyOn(database, 'transaction').mockImplementation((run) => - transaction((db) => { - try { - const result = run(db) - expect(journal.snapshot()).toEqual(before) - expect(published).toEqual([]) - expect(frames).toEqual([]) - return result - } catch (error) { - sawEarlierInserts.push(rows().length - diskBefore.length) - expect(journal.snapshot()).toEqual(before) - expect(published).toEqual([]) - expect(frames).toEqual([]) - throw error - } - }) - ) - database.db.exec(`CREATE TEMP TRIGGER reject_later_settlement BEFORE INSERT ON journal_rows - WHEN NEW.session_id = '${SESSION}' AND json_extract(NEW.row_json, '$.recovered') = 1 - AND json_extract(NEW.row_json, '$.kind') = '${kind}' - AND NEW.seq > ${before.cursor.sequence + 1} - BEGIN SELECT RAISE(ABORT, 'later settlement row rejected'); END`) - - expect(await settle()).toMatchObject({ ok: false }) - expect(sawEarlierInserts[0]).toBeGreaterThan(0) - expect(rows()).toEqual(diskBefore) - expect(journal.cursor()).toEqual(before.cursor) - expect(journal.snapshot()).toEqual(before) - expect(published).toEqual([]) - expect(frames).toEqual([]) - - vi.restoreAllMocks() - - const other = await open('other-chat') - await expect( - other.appendSubmission({ - clientMessageId: 'unrelated', - payloadFingerprint: 'other', - fence: 0, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } - }) - ).resolves.toMatchObject({ sequence: 2 }) - - database.db.exec('DROP TRIGGER reject_later_settlement') - vi.spyOn(database, 'transaction').mockImplementation((run) => - transaction((db) => { - const result = run(db) - expect(journal.snapshot()).toEqual(before) - expect(published).toEqual([]) - expect(frames).toEqual([]) - return result - }) - ) - expect(await settle()).toMatchObject({ ok: true }) - vi.restoreAllMocks() - const settled = journal.snapshot() - expect(published).toEqual([settled]) - expect(frames.length).toBeGreaterThan(0) - expect( - emittedStates.every((state) => state.cursor.sequence === settled.cursor.sequence) - ).toBe(true) - expect( - emittedStates.every((state) => - state.items.every( - (item) => item.body.kind !== 'tool-call' || item.body.state === 'failed' - ) - ) - ).toBe(true) - expect(frames.at(-1)).toMatchObject({ type: 'batch', batch: { cursor: settled.cursor } }) - const frameCount = frames.length - expect(settled.submissions[0]).toMatchObject({ dispatchState: 'unknown', recovered: true }) - expect(journal.runningTurn()?.turnId ?? null).toBeNull() - const calls = settled.items.filter((item) => item.body.kind === 'tool-call') - expect(calls).toHaveLength(201) - expect( - calls.every((item) => item.body.kind === 'tool-call' && item.body.state === 'failed') - ).toBe(true) - expect(calls[0]).toMatchObject({ - observedAt: 1_000, - agentId: 'child', - revision: 2, - body: { endedAs: 'interrupted', input: { content: '界😀'.repeat(220_000) } } - }) - const committed = rows().slice(diskBefore.length) - expect(committed.map((row) => row.kind)).toEqual([ - 'dispatch', - 'lifecycle-batch', - 'item', - 'lifecycle-batch', - 'lifecycle-batch' - ]) - expect(committed.every((row) => row.recovered)).toBe(true) - expect(await settle()).toMatchObject({ ok: true }) - expect(journal.snapshot()).toEqual(settled) - expect(rows().slice(diskBefore.length)).toEqual(committed) - expect(published).toEqual([settled]) - expect(frames).toHaveLength(frameCount) - await journal.close() - journal = await open() - expect(journal.snapshot()).toEqual(settled) - expect(await settle()).toMatchObject({ ok: true }) - expect(journal.snapshot()).toEqual(settled) - } - ) - - it('writes an all-oversized settlement with full bodies, and deduplicates it after replay', async () => { - await seedWork(2) - const calls = journal.snapshot().items.filter((item) => item.body.kind === 'tool-call') - const mutations: JournalLifecycleMutationInput[] = calls.map((item, index) => ({ - kind: 'item', - identity: { provider: 'orca', clientMessageId: `call-${index}` }, - body: { - kind: 'tool-call', - name: 'Write', - state: 'failed', - endedAs: 'interrupted', - input: { content: 'x'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES + index) } - }, - turnScope: item.turnScope ?? AGENT_JOURNAL_THREAD_SCOPE - })) - const input = { settlementId: 'all-oversized:7', fence: 7, recovered: true as const, mutations } - const before = journal.cursor() - const published: AgentJournalSnapshot[] = [] - journal.observeCommits(() => published.push(journal.snapshot())) - await journal.appendLifecycleBatch(input) - const settled = journal.snapshot() - expect(journal.cursor().sequence).toBe(before.sequence + 2) - expect( - rows() - .slice(-2) - .map((row) => row.kind) - ).toEqual(['item', 'item']) - expect( - settled.items.filter((item) => item.body.kind === 'tool-call').map((item) => item.body) - ).toEqual(mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : []))) - expect(published).toEqual([settled]) - await journal.appendLifecycleBatch(input) - expect(journal.snapshot()).toEqual(settled) - expect(published).toEqual([settled]) - expect( - await journal.appendSteps([ - { - kind: 'settlement', - batch: { - settlementId: input.settlementId, - fence: input.fence, - recovered: true, - resolve: () => mutations - } - } - ]) - ).toEqual([false]) - expect(journal.snapshot()).toEqual(settled) - await journal.close() - journal = await open() - await journal.appendLifecycleBatch(input) - expect(journal.snapshot()).toEqual(settled) - }) - - it('settles stale work through the same oversized fallback without duplicating its outcome', async () => { - await seedWork(1) - const input = { - journal, - sessionId: SESSION, - fence: 8, - acquisitionGeneration: 'new-generation', - deathEvidence: { - kind: 'exit-observed' as const, - ownerFence: 7, - observedAt: 1_000, - detail: '' - } - } - expect(await settleStaleStructuredAgentSessionState(input)).toBeGreaterThan(0) - const settled = journal.snapshot() - expect(await settleStaleStructuredAgentSessionState(input)).toBe(0) - expect(journal.snapshot()).toEqual(settled) - expect(rows().some((row) => row.kind === 'item' && row.recovered)).toBe(true) - }) - - it('uses an ordinary recovered tombstone when the settlement namespace exceeds the batch cap', async () => { - const identity = { provider: 'orca' as const, clientMessageId: 'remove-item' } - await journal.appendItem( - identity, - { kind: 'status', text: 'remove me' }, - { - fence: 7, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ) - const before = journal.cursor() - const input = { - settlementId: 'x'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES), - fence: 7, - recovered: true as const, - mutations: [{ kind: 'tombstone' as const, identity }] - } - await journal.appendLifecycleBatch(input) - expect(journal.snapshot().items).toEqual([]) - expect(journal.cursor().sequence).toBe(before.sequence + 1) - expect(rows().at(-1)).toMatchObject({ kind: 'tombstone', recovered: true }) - await journal.appendLifecycleBatch(input) - expect(journal.cursor().sequence).toBe(before.sequence + 1) - }) - - it.each(['startup', 'stop'] as const)( - 'rolls back the %s submission verdict with its oversized item', - async (mode) => { - await journal.appendSubmission({ - clientMessageId: 'unanswered', - payloadFingerprint: 'unanswered', - fence: 7, - handoverRecorded: true, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'next' }] } - }) - await journal.resolveDispatch({ - clientMessageId: 'unanswered', - state: 'pending', - fence: 7, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - if (mode === 'stop') { - await journal.appendStopEvent({ reason: 'user-stop' }, 7) - } - await journal.appendItem( - { provider: 'orca', clientMessageId: 'oversized-call' }, - { - kind: 'tool-call', - name: 'Write', - input: 'x'.repeat(MAX_JOURNAL_LIFECYCLE_BATCH_BYTES), - state: 'running' - }, - { fence: 7, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const input = { - journal, - sessionId: SESSION, - fence: 7, - settlementId: `${mode}:7`, - verdict: { state: 'interrupted' as const, completedAt: 1_000 }, - pendingSubmissionReason: 'owner exited', - showUnexpectedExitOutcome: mode === 'startup', - ...(mode === 'startup' ? { exitedDuringStartup: { generation: 'start-7' } } : {}) - } - const database = openTestJournalHostDatabase(root) - const before = journal.snapshot() - const diskBefore = rows() - const published: AgentJournalSnapshot[] = [] - journal.observeCommits(() => published.push(journal.snapshot())) - database.db.exec(`CREATE TEMP TRIGGER reject_submission_settlement_item - BEFORE INSERT ON journal_rows - WHEN NEW.session_id = '${SESSION}' AND json_extract(NEW.row_json, '$.recovered') = 1 - AND json_extract(NEW.row_json, '$.kind') = 'item' - BEGIN SELECT RAISE(ABORT, 'item rejected after submission'); END`) - expect(await settleStructuredAgentSessionDeadGeneration(input)).toMatchObject({ ok: false }) - expect(journal.snapshot()).toEqual(before) - expect(rows()).toEqual(diskBefore) - expect(published).toEqual([]) - database.db.exec('DROP TRIGGER reject_submission_settlement_item') - expect(await settleStructuredAgentSessionDeadGeneration(input)).toEqual({ ok: true }) - expect(journal.submission('unanswered')).toMatchObject({ - dispatchState: 'rejected', - recovered: true, - rejection: { kind: mode === 'stop' ? 'cancelled' : 'providerStartFailed' } - }) - const committed = rows().slice(diskBefore.length) - expect(committed.filter((row) => row.kind === 'dispatch')).toHaveLength(1) - expect(published).toEqual([journal.snapshot()]) - expect(await settleStructuredAgentSessionDeadGeneration(input)).toEqual({ ok: true }) - expect(rows().slice(diskBefore.length)).toEqual(committed) - expect(published).toHaveLength(1) - } - ) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-crash-boundary.spec.ts b/src/main/native-chat/agent-session-journal/journal-crash-boundary.spec.ts new file mode 100644 index 00000000000..4fd6a5e986a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-crash-boundary.spec.ts @@ -0,0 +1,499 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// The crash boundary: the host wrote a submission row, dispatched, and died +// before it learned whether the provider took the message. Replay must reconcile +// without duplicating the user's message and without losing it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { digestPayload } from './journal-payload-bounds' +import { + reconcileSubmissions, + type ProviderHistoryItem, + type ProviderHistoryWindow +} from './journal-submission-reconciler' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalCrashBoundaryTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + + const TURN_ID = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' + + const ACCEPTED_IDENTITY: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: TURN_ID, + ordinal: 0 + } + + let root: string + let clock = 1_000 + + function tick(): number { + clock += 1 + return clock + } + + function userMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } + + const LEGACY_CODEX_TURN_UNNAMED = 'codex app-server started a turn it did not name in time' + + const journals = createTrackedJournalOpener() + + async function open() { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) + } + + /** A Codex `userMessage` history item; `clientId` is the echoed client message id. */ + function history(input: { + itemId: string + clientId: string | null + text: string + ordinal: number + }): ProviderHistoryItem { + return { + providerItemId: input.itemId, + clientMessageId: input.clientId, + payloadFingerprint: digestPayload(input.text), + identity: { provider: 'codex', threadId: 'thread-1', turnId: TURN_ID, ordinal: input.ordinal } + } + } + + function window( + items: ProviderHistoryItem[], + overrides: Partial = {} + ): ProviderHistoryWindow { + return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-crash-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('crash between provider accept and journal commit', () => { + it('reconciles the echo into the existing bubble instead of duplicating it', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + // Host dies here: the provider accepted, but no dispatch row was written. + + const restarted = await open() + expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1']) + await restarted.markPendingSubmissionsUnknown(2) + expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') + // Marks the send as outlived by its writer, so no reader reports it as still working. + expect(restarted.submissions()[0]?.recovered).toBe(true) + + const [outcome] = reconcileSubmissions({ + submissions: restarted.submissions(), + history: window([ + history({ itemId: 'item-1', clientId: 'cm_1', text: 'deploy the thing', ordinal: 0 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) + + if (outcome?.outcome !== 'accepted') { + throw new Error('expected the echoed submission to reconcile as accepted') + } + await restarted.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: outcome.identity, + fence: 2, + recovered: true + }) + // The provider's own copy of the message arrives next, under the identity + // reconciliation adopted. It must land in the bubble the user already sees. + await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { + fence: 2, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + + const items = restarted.snapshot().items + expect(items).toHaveLength(1) + expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1')) + expect(restarted.receiptFor('cm_1')?.providerItemId).toBe( + agentJournalItemKey(outcome.identity) + ) + }) + + it('retires an ack timeout on restart without changing its delivery verdict', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_timeout', + payloadFingerprint: digestPayload('slow'), + body: userMessage('slow'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_timeout', + state: 'unknown', + reason: 'ack timeout', + fence: 1 + }) + expect(hasUnansweredStructuredAgentSessionDispatch(journal.submissions())).toBe(true) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') + expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) + const cursor = restarted.cursor() + await restarted.markPendingSubmissionsUnknown(2) + expect(restarted.cursor()).toEqual(cursor) + }) + + it('leaves a rejected write failure settled across a restart', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_write_failed', + payloadFingerprint: digestPayload('never left the process'), + body: userMessage('never left the process'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_write_failed', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { + surface: 'rejection' + }), + fence: 1 + }) + + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + // A restart re-opens what it could not answer. This one is already answered, + // so recovery must not reopen it as doubt. + expect(restarted.submissions()[0]).toMatchObject({ + dispatchState: 'rejected', + reason: 'provider_write_failed' + }) + expect(restarted.submissions()[0]?.recovered).toBeUndefined() + expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) + }) + + // Only an older Orca minted this reason -- Codex now settles a send on the + // provider echo -- but rows written under it still come back from disk. + it('keeps a codex turn it could not name in doubt, never rejected', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_codex_unnamed', + payloadFingerprint: digestPayload('codex is running this'), + body: userMessage('codex is running this'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_codex_unnamed', + state: 'unknown', + reason: LEGACY_CODEX_TURN_UNNAMED, + fence: 1 + }) + + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2, 'provider_exited_before_acknowledgement') + + // The turn IS started; recovery may not overwrite that with a weaker guess, + // and it may never become a rejection, which would license a re-delivery. + expect(restarted.submissions()[0]).toMatchObject({ + dispatchState: 'unknown', + reason: LEGACY_CODEX_TURN_UNNAMED, + recovered: true + }) + }) + + it('reports a rejected submission as never delivered, and never re-sends it', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('never landed'), + body: userMessage('never landed'), + fence: 1 + }) + + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + const [outcome] = reconcileSubmissions({ + submissions: restarted.submissions(), + history: window([]) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'rejected', + reason: 'not_delivered' + }) + + await restarted.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), + fence: 2, + recovered: true + }) + // The bubble survives with an explicit terminal state — the message is not + // silently retried and not silently dropped. + expect(restarted.snapshot().items).toHaveLength(1) + expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected') + expect(restarted.receiptFor('cm_1')).toBeNull() + }) + + it('survives replay of an already-reconciled journal without changing the answer', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('once'), + body: userMessage('once'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: ACCEPTED_IDENTITY, + fence: 1 + }) + const settled = journal.snapshot() + + const reopened = await open() + expect(reopened.snapshot()).toEqual(settled) + expect(reopened.pendingSubmissions()).toHaveLength(0) + expect( + reconcileSubmissions({ submissions: reopened.submissions(), history: window([]) }) + ).toEqual([]) + }) + + it('keeps the receipt across a reopen', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('kept'), + body: userMessage('kept'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'accepted', + providerIdentity: ACCEPTED_IDENTITY, + fence: 1 + }) + await journal.close() + + const reopened = await open() + expect(reopened.receiptFor('cm_1')?.providerItemId).toBe( + agentJournalItemKey(ACCEPTED_IDENTITY) + ) + }) + }) + + describe('reconciliation matching', () => { + const submissions = [ + { + clientMessageId: 'cm_1', + fence: 1, + payloadFingerprint: digestPayload('same text'), + dispatchState: 'unknown' as const, + providerItemId: null, + reason: null, + submittedAt: 1, + resolvedAt: null + }, + { + clientMessageId: 'cm_2', + fence: 1, + payloadFingerprint: digestPayload('same text'), + dispatchState: 'unknown' as const, + providerItemId: null, + reason: null, + submittedAt: 2, + resolvedAt: null + } + ] + + it('matches each submission to its own echo when the provider carries client ids', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: 'cm_1', text: 'same text', ordinal: 0 }), + history({ itemId: 'item-3', clientId: 'cm_2', text: 'same text', ordinal: 2 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-1' }), + expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-3' }) + ]) + }) + + it('refuses to guess between two identical payloads with no id to tell them apart', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), + history({ itemId: 'item-3', clientId: null, text: 'same text', ordinal: 2 }) + ]) + }) + expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) + expect(outcomes[0]).toMatchObject({ reason: 'ambiguous_match' }) + }) + + it('does not assign one matching item to the first of two identical sends', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }) + ]) + }) + + expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) + expect(outcomes.map((outcome) => ('reason' in outcome ? outcome.reason : null))).toEqual([ + 'ambiguous_match', + 'ambiguous_match' + ]) + }) + + it('uses a unique fingerprint only as a tiebreak when no id is echoed', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([ + history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), + history({ itemId: 'item-2', clientId: null, text: 'something else', ordinal: 1 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) + }) + + it('never matches on text alone when the fingerprint disagrees', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([ + { + providerItemId: 'item-1', + clientMessageId: null, + payloadFingerprint: digestPayload('different payload, same rendered text'), + identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } + } + ]) + }) + expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' }) + }) + + it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { + const outcomes = reconcileSubmissions({ + submissions, + history: window([ + history({ itemId: 'item-1', clientId: 'cm_2', text: 'same text', ordinal: 0 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }), + expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' }) + ]) + }) + + it('re-matches a submission on the journal key it already adopted, not the raw provider id', () => { + const [outcome] = reconcileSubmissions({ + submissions: [ + { ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) } + ], + history: window([ + // The provider renumbered its raw id; the identity-derived key did not move. + history({ itemId: 'item-7', clientId: null, text: 'unrelated', ordinal: 0 }) + ]) + }) + expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-7' }) + }) + + it('never hands one provider item to two submissions', () => { + const outcomes = reconcileSubmissions({ + submissions: [ + { ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }, + submissions[1]! + ], + // One item, wanted by both passes: cm_1 adopted its key, cm_2 is echoed on + // it. Adopting it twice would render the same provider message twice. + history: window([ + history({ itemId: 'item-7', clientId: 'cm_2', text: 'same text', ordinal: 0 }) + ]) + }) + expect(outcomes).toEqual([ + expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }), + expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' }) + ]) + }) + + it('stays unknown when the history boundary cannot be trusted', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([], { boundaryConsistent: false }) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'unknown', + reason: 'history_boundary_inconsistent' + }) + }) + + it('stays unknown while a turn is still running', () => { + const [outcome] = reconcileSubmissions({ + submissions: [submissions[0]!], + history: window([], { turnInFlight: true }) + }) + expect(outcome).toEqual({ + clientMessageId: 'cm_1', + outcome: 'unknown', + reason: 'turn_in_flight' + }) + }) + + it('leaves settled submissions alone', () => { + expect( + reconcileSubmissions({ + submissions: [ + { + ...submissions[0]!, + dispatchState: 'accepted', + providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) + }, + { ...submissions[1]!, dispatchState: 'rejected' } + ], + history: window([]) + }) + ).toEqual([]) + }) + }) +} 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 deleted file mode 100644 index 278fea93973..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-crash-boundary.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// The crash boundary: the host wrote a submission row, dispatched, and died -// before it learned whether the provider took the message. Replay must reconcile -// without duplicating the user's message and without losing it. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { hasUnansweredStructuredAgentSessionDispatch } from '../../../shared/structured-agent-session-projection' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { digestPayload } from './journal-payload-bounds' -import { - reconcileSubmissions, - type ProviderHistoryItem, - type ProviderHistoryWindow -} from './journal-submission-reconciler' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -const TURN_ID = '019fd8ca-edbe-7c43-b231-4c7aea3a2d89' - -const ACCEPTED_IDENTITY: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'thread-1', - turnId: TURN_ID, - ordinal: 0 -} - -let root: string -let clock = 1_000 - -function tick(): number { - clock += 1 - return clock -} - -function userMessage(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -const LEGACY_CODEX_TURN_UNNAMED = 'codex app-server started a turn it did not name in time' - -const journals = createTrackedJournalOpener() - -async function open() { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}` - }) -} - -/** A Codex `userMessage` history item; `clientId` is the echoed client message id. */ -function history(input: { - itemId: string - clientId: string | null - text: string - ordinal: number -}): ProviderHistoryItem { - return { - providerItemId: input.itemId, - clientMessageId: input.clientId, - payloadFingerprint: digestPayload(input.text), - identity: { provider: 'codex', threadId: 'thread-1', turnId: TURN_ID, ordinal: input.ordinal } - } -} - -function window( - items: ProviderHistoryItem[], - overrides: Partial = {} -): ProviderHistoryWindow { - return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-crash-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('crash between provider accept and journal commit', () => { - it('reconciles the echo into the existing bubble instead of duplicating it', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - // Host dies here: the provider accepted, but no dispatch row was written. - - const restarted = await open() - expect(restarted.pendingSubmissions().map((entry) => entry.clientMessageId)).toEqual(['cm_1']) - await restarted.markPendingSubmissionsUnknown(2) - expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') - // Marks the send as outlived by its writer, so no reader reports it as still working. - expect(restarted.submissions()[0]?.recovered).toBe(true) - - const [outcome] = reconcileSubmissions({ - submissions: restarted.submissions(), - history: window([ - history({ itemId: 'item-1', clientId: 'cm_1', text: 'deploy the thing', ordinal: 0 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) - - if (outcome?.outcome !== 'accepted') { - throw new Error('expected the echoed submission to reconcile as accepted') - } - await restarted.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'accepted', - providerIdentity: outcome.identity, - fence: 2, - recovered: true - }) - // The provider's own copy of the message arrives next, under the identity - // reconciliation adopted. It must land in the bubble the user already sees. - await restarted.appendItem(outcome.identity, userMessage('deploy the thing'), { - fence: 2, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - - const items = restarted.snapshot().items - expect(items).toHaveLength(1) - expect(items[0]?.itemId).toBe(agentJournalSubmissionKey('cm_1')) - expect(restarted.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(outcome.identity)) - }) - - it('retires an ack timeout on restart without changing its delivery verdict', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_timeout', - payloadFingerprint: digestPayload('slow'), - body: userMessage('slow'), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'cm_timeout', - state: 'unknown', - reason: 'ack timeout', - fence: 1 - }) - expect(hasUnansweredStructuredAgentSessionDispatch(journal.submissions())).toBe(true) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - expect(restarted.submissions()[0]?.dispatchState).toBe('unknown') - expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) - const cursor = restarted.cursor() - await restarted.markPendingSubmissionsUnknown(2) - expect(restarted.cursor()).toEqual(cursor) - }) - - it('leaves a rejected write failure settled across a restart', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_write_failed', - payloadFingerprint: digestPayload('never left the process'), - body: userMessage('never left the process'), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'cm_write_failed', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('writeFailed'), { surface: 'rejection' }), - fence: 1 - }) - - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - // A restart re-opens what it could not answer. This one is already answered, - // so recovery must not reopen it as doubt. - expect(restarted.submissions()[0]).toMatchObject({ - dispatchState: 'rejected', - reason: 'provider_write_failed' - }) - expect(restarted.submissions()[0]?.recovered).toBeUndefined() - expect(hasUnansweredStructuredAgentSessionDispatch(restarted.submissions())).toBe(false) - }) - - // Only an older Orca minted this reason -- Codex now settles a send on the - // provider echo -- but rows written under it still come back from disk. - it('keeps a codex turn it could not name in doubt, never rejected', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_codex_unnamed', - payloadFingerprint: digestPayload('codex is running this'), - body: userMessage('codex is running this'), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'cm_codex_unnamed', - state: 'unknown', - reason: LEGACY_CODEX_TURN_UNNAMED, - fence: 1 - }) - - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2, 'provider_exited_before_acknowledgement') - - // The turn IS started; recovery may not overwrite that with a weaker guess, - // and it may never become a rejection, which would license a re-delivery. - expect(restarted.submissions()[0]).toMatchObject({ - dispatchState: 'unknown', - reason: LEGACY_CODEX_TURN_UNNAMED, - recovered: true - }) - }) - - it('reports a rejected submission as never delivered, and never re-sends it', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('never landed'), - body: userMessage('never landed'), - fence: 1 - }) - - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - const [outcome] = reconcileSubmissions({ - submissions: restarted.submissions(), - history: window([]) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'rejected', - reason: 'not_delivered' - }) - - await restarted.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { - surface: 'rejection' - }), - fence: 2, - recovered: true - }) - // The bubble survives with an explicit terminal state — the message is not - // silently retried and not silently dropped. - expect(restarted.snapshot().items).toHaveLength(1) - expect(restarted.snapshot().submissions[0]?.dispatchState).toBe('rejected') - expect(restarted.receiptFor('cm_1')).toBeNull() - }) - - it('survives replay of an already-reconciled journal without changing the answer', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('once'), - body: userMessage('once'), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'accepted', - providerIdentity: ACCEPTED_IDENTITY, - fence: 1 - }) - const settled = journal.snapshot() - - const reopened = await open() - expect(reopened.snapshot()).toEqual(settled) - expect(reopened.pendingSubmissions()).toHaveLength(0) - expect( - reconcileSubmissions({ submissions: reopened.submissions(), history: window([]) }) - ).toEqual([]) - }) - - it('keeps the receipt across a reopen', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('kept'), - body: userMessage('kept'), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'accepted', - providerIdentity: ACCEPTED_IDENTITY, - fence: 1 - }) - await journal.close() - - const reopened = await open() - expect(reopened.receiptFor('cm_1')?.providerItemId).toBe(agentJournalItemKey(ACCEPTED_IDENTITY)) - }) -}) - -describe('reconciliation matching', () => { - const submissions = [ - { - clientMessageId: 'cm_1', - fence: 1, - payloadFingerprint: digestPayload('same text'), - dispatchState: 'unknown' as const, - providerItemId: null, - reason: null, - submittedAt: 1, - resolvedAt: null - }, - { - clientMessageId: 'cm_2', - fence: 1, - payloadFingerprint: digestPayload('same text'), - dispatchState: 'unknown' as const, - providerItemId: null, - reason: null, - submittedAt: 2, - resolvedAt: null - } - ] - - it('matches each submission to its own echo when the provider carries client ids', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: 'cm_1', text: 'same text', ordinal: 0 }), - history({ itemId: 'item-3', clientId: 'cm_2', text: 'same text', ordinal: 2 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-1' }), - expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-3' }) - ]) - }) - - it('refuses to guess between two identical payloads with no id to tell them apart', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), - history({ itemId: 'item-3', clientId: null, text: 'same text', ordinal: 2 }) - ]) - }) - expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) - expect(outcomes[0]).toMatchObject({ reason: 'ambiguous_match' }) - }) - - it('does not assign one matching item to the first of two identical sends', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }) - ]) - }) - - expect(outcomes.map((outcome) => outcome.outcome)).toEqual(['unknown', 'unknown']) - expect(outcomes.map((outcome) => ('reason' in outcome ? outcome.reason : null))).toEqual([ - 'ambiguous_match', - 'ambiguous_match' - ]) - }) - - it('uses a unique fingerprint only as a tiebreak when no id is echoed', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([ - history({ itemId: 'item-1', clientId: null, text: 'same text', ordinal: 0 }), - history({ itemId: 'item-2', clientId: null, text: 'something else', ordinal: 1 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-1' }) - }) - - it('never matches on text alone when the fingerprint disagrees', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([ - { - providerItemId: 'item-1', - clientMessageId: null, - payloadFingerprint: digestPayload('different payload, same rendered text'), - identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 } - } - ]) - }) - expect(outcome).toMatchObject({ outcome: 'rejected', reason: 'not_delivered' }) - }) - - it('lets a strong client-id match win an item a weaker fingerprint would have claimed', () => { - const outcomes = reconcileSubmissions({ - submissions, - history: window([ - history({ itemId: 'item-1', clientId: 'cm_2', text: 'same text', ordinal: 0 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', outcome: 'rejected' }), - expect.objectContaining({ clientMessageId: 'cm_2', providerItemId: 'item-1' }) - ]) - }) - - it('re-matches a submission on the journal key it already adopted, not the raw provider id', () => { - const [outcome] = reconcileSubmissions({ - submissions: [{ ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }], - history: window([ - // The provider renumbered its raw id; the identity-derived key did not move. - history({ itemId: 'item-7', clientId: null, text: 'unrelated', ordinal: 0 }) - ]) - }) - expect(outcome).toMatchObject({ outcome: 'accepted', providerItemId: 'item-7' }) - }) - - it('never hands one provider item to two submissions', () => { - const outcomes = reconcileSubmissions({ - submissions: [ - { ...submissions[0]!, providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) }, - submissions[1]! - ], - // One item, wanted by both passes: cm_1 adopted its key, cm_2 is echoed on - // it. Adopting it twice would render the same provider message twice. - history: window([ - history({ itemId: 'item-7', clientId: 'cm_2', text: 'same text', ordinal: 0 }) - ]) - }) - expect(outcomes).toEqual([ - expect.objectContaining({ clientMessageId: 'cm_1', providerItemId: 'item-7' }), - expect.objectContaining({ clientMessageId: 'cm_2', outcome: 'rejected' }) - ]) - }) - - it('stays unknown when the history boundary cannot be trusted', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([], { boundaryConsistent: false }) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'unknown', - reason: 'history_boundary_inconsistent' - }) - }) - - it('stays unknown while a turn is still running', () => { - const [outcome] = reconcileSubmissions({ - submissions: [submissions[0]!], - history: window([], { turnInFlight: true }) - }) - expect(outcome).toEqual({ - clientMessageId: 'cm_1', - outcome: 'unknown', - reason: 'turn_in_flight' - }) - }) - - it('leaves settled submissions alone', () => { - expect( - reconcileSubmissions({ - submissions: [ - { - ...submissions[0]!, - dispatchState: 'accepted', - providerItemId: agentJournalItemKey(ACCEPTED_IDENTITY) - }, - { ...submissions[1]!, dispatchState: 'rejected' } - ], - history: window([]) - }) - ).toEqual([]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-database-contracts.test.ts b/src/main/native-chat/agent-session-journal/journal-database-contracts.test.ts new file mode 100644 index 00000000000..215585b5507 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-database-contracts.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, vi } from 'vitest' +import { registerCommandReceiptSchemaTests } from './command-receipt-schema.spec' +import { registerCommandReceiptTableTests } from './command-receipt-table.spec' +import { registerCommandReceiptTransactionTests } from './command-receipt-transaction.spec' +import { registerJournalDatabaseTests } from './journal-database.spec' +import { registerJournalHostDatabaseTests } from './journal-host-database.spec' +import { registerJournalRowWriterTests } from './journal-row-writer.spec' +import { registerJournalWriteQueueTests } from './journal-write-queue.spec' +import { registerJournalStoreSchemaTests } from './journal-store-schema.spec' +import { registerJournalRowSchemaVersionTests } from './journal-row-schema-version.spec' + +afterEach(() => vi.restoreAllMocks()) + +describe('command-receipt-schema', registerCommandReceiptSchemaTests) +describe('command-receipt-table', registerCommandReceiptTableTests) +describe('command-receipt-transaction', registerCommandReceiptTransactionTests) +describe('journal-database', registerJournalDatabaseTests) +describe('journal-host-database', registerJournalHostDatabaseTests) +describe('journal-row-writer', registerJournalRowWriterTests) +describe('journal-write-queue', registerJournalWriteQueueTests) +describe('journal-store-schema', registerJournalStoreSchemaTests) +describe('journal-row-schema-version', registerJournalRowSchemaVersionTests) diff --git a/src/main/native-chat/agent-session-journal/journal-database.spec.ts b/src/main/native-chat/agent-session-journal/journal-database.spec.ts new file mode 100644 index 00000000000..eb7f72fe551 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-database.spec.ts @@ -0,0 +1,264 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import Database from '../../sqlite/sync-database' +import { + JOURNAL_BUSY_TIMEOUT_MS, + JOURNAL_SIZE_LIMIT_BYTES, + journalPragmaNumber, + openJournalDatabase +} from './journal-database' +import { + createJournalTablesSql, + JOURNAL_DB_OLDEST_RELEASED_VERSION, + JOURNAL_DB_SCHEMA_VERSION +} from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' +import { JournalUnreleasedSchemaError } from './journal-open-failure' +import { + deleteJournalEpochRows, + insertJournalRow, + iterateJournalEpochRows, + publishJournalSessionEpoch, + readJournalRowsAfter, + readJournalSessionEpoch +} from './journal-row-table' +import type { JournalRow } from './journal-row-schema' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' + +export function registerJournalDatabaseTests(): void { + let root: string + let dbPath: string + + function epochRow(seq: number, epoch = 'epoch-1'): JournalRow { + return { + kind: 'epoch', + reason: 'session_created', + providerHandle: { kind: 'codex', threadId: 'thread-1' }, + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + seq, + fence: 0, + ts: 1_700_000_000_000 + seq + } + } + + 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 = journalDatabasePath(root) + }) + + afterEach(async () => { + vi.restoreAllMocks() + await rm(root, { recursive: true, force: true }) + }) + + describe('the host journal database open', () => { + it('creates every table and reads back every load-bearing pragma', () => { + const db = openJournalDatabase(dbPath).db + try { + const tables = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") + .all() + .map((entry) => (entry as { name: string }).name) + expect(tables).toEqual( + expect.arrayContaining([ + 'journal_imports', + 'journal_repairs', + 'journal_rows', + 'journal_sessions', + 'journal_set_aside' + ]) + ) + expect(db.pragma('journal_mode', { simple: true })).toBe('wal') + expect(journalPragmaNumber(db, 'synchronous')).toBe(2) + expect(journalPragmaNumber(db, 'checkpoint_fullfsync')).toBe(1) + expect(journalPragmaNumber(db, 'busy_timeout')).toBe(JOURNAL_BUSY_TIMEOUT_MS) + expect(journalPragmaNumber(db, 'foreign_keys')).toBe(1) + // 2 is INCREMENTAL, which only takes on an empty file before WAL. + expect(journalPragmaNumber(db, 'auto_vacuum')).toBe(2) + expect(journalPragmaNumber(db, 'journal_size_limit')).toBe(JOURNAL_SIZE_LIMIT_BYTES) + expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) + } finally { + db.close() + } + }) + + // T5: a newer build's database opens read-only, its rows readable, and is left byte-identical. + it('opens a newer user_version read-only without touching the file', async () => { + const seeded = openJournalDatabase(dbPath).db + publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') + insertJournalRow(seeded, 'session-1', epochRow(1)) + seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) + seeded.close() + const before = await digest(dbPath) + + const 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) + // 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 () => { + 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() + root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) + }) + }) + + describe('journal row statements', () => { + it('serves replay, resume and an epoch discard', () => { + const db = openJournalDatabase(dbPath).db + try { + db.exec('BEGIN IMMEDIATE') + for (let seq = 1; seq <= 5; seq += 1) { + insertJournalRow(db, 'session-1', epochRow(seq)) + } + insertJournalRow(db, 'session-2', epochRow(1)) + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-1') + db.exec('COMMIT') + + expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1') + expect(readJournalSessionEpoch(db, 'absent')).toBeNull() + expect(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 + ]) + + // Another chat in the same file is untouched by this chat's discard. + deleteJournalEpochRows(db, 'session-1', 'epoch-1') + expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) + expect(rowsOf(db, 'session-2', 'epoch-1')).toEqual([1]) + } finally { + db.close() + } + }) + + it('refuses a duplicate sequence inside one epoch of one chat', () => { + const db = openJournalDatabase(dbPath).db + 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, 'session-2', epochRow(1)) + } finally { + db.close() + } + }) + + it('moves the epoch pointer in place', () => { + const db = openJournalDatabase(dbPath).db + try { + publishJournalSessionEpoch(db, SESSION, 'epoch-1') + publishJournalSessionEpoch(db, SESSION, 'epoch-2') + expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-2') + expect(db.prepare('SELECT count(*) AS total FROM journal_sessions').get()).toMatchObject({ + total: 1 + }) + } finally { + db.close() + } + }) + }) + + describe('schema creation', () => { + 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 ( + this: Database.Database, + sql: string, + options?: { simple?: boolean } + ) { + if (sql.startsWith('user_version =')) { + throw new Error('crash before the version is published') + } + return original.call(this, sql, options) + }) + + expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published') + pragma.mockRestore() + + const inspected = new Database(dbPath) + try { + expect(inspected.pragma('user_version', { simple: true })).toBe(0) + expect( + inspected + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journal_rows'" + ) + .get() + ).toBeUndefined() + } finally { + inspected.close() + } + }) + + it('gives a version 3 database the chat record tables, keeping its rows', () => { + const released = new Database(dbPath) + released.exec(createJournalTablesSql()) + released.exec("INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1')") + released.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) + released.close() + + const { db } = openJournalDatabase(dbPath) + try { + expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) + expect(db.prepare('SELECT session_id FROM journal_sessions').all()).toEqual([ + { session_id: 's1' } + ]) + expect(db.prepare('SELECT count(*) AS n FROM agent_session_records').get()).toEqual({ + n: 0 + }) + } finally { + db.close() + } + }) + + // Versions 1 and 2 were written only by unreleased builds; neither is migrated. + it.each([1, 2])('refuses a version %i database without touching the file', async (version) => { + const earlier = new Database(dbPath) + earlier.pragma('journal_mode = WAL') + earlier.exec(` +CREATE TABLE journal_rows (id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, row_json TEXT NOT NULL); +CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, + epoch TEXT NOT NULL, block INTEGER NOT NULL UNIQUE, status_json TEXT, status_seq INTEGER); +INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) + earlier.pragma(`user_version = ${version}`) + earlier.pragma('journal_mode = DELETE') + earlier.close() + const before = await digest(dbPath) + + expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) + expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) + + expect(await digest(dbPath)).toBe(before) + await expect(stat(`${dbPath}-wal`)).rejects.toThrow() + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-database.test.ts b/src/main/native-chat/agent-session-journal/journal-database.test.ts deleted file mode 100644 index 473504f51ec..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-database.test.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { createHash } from 'node:crypto' -import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import Database from '../../sqlite/sync-database' -import { - JOURNAL_BUSY_TIMEOUT_MS, - JOURNAL_SIZE_LIMIT_BYTES, - journalPragmaNumber, - openJournalDatabase -} from './journal-database' -import { - createJournalTablesSql, - JOURNAL_DB_OLDEST_RELEASED_VERSION, - JOURNAL_DB_SCHEMA_VERSION -} from './journal-database-schema' -import { journalDatabasePath } from './journal-host-database' -import { JournalUnreleasedSchemaError } from './journal-open-failure' -import { - deleteJournalEpochRows, - insertJournalRow, - iterateJournalEpochRows, - publishJournalSessionEpoch, - readJournalRowsAfter, - readJournalSessionEpoch -} from './journal-row-table' -import type { JournalRow } from './journal-row-schema' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' - -let root: string -let dbPath: string - -function epochRow(seq: number, epoch = 'epoch-1'): JournalRow { - return { - kind: 'epoch', - reason: 'session_created', - providerHandle: { kind: 'codex', threadId: 'thread-1' }, - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - epoch, - seq, - fence: 0, - ts: 1_700_000_000_000 + seq - } -} - -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 = journalDatabasePath(root) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await rm(root, { recursive: true, force: true }) -}) - -describe('the host journal database open', () => { - it('creates every table and reads back every load-bearing pragma', () => { - const db = openJournalDatabase(dbPath).db - try { - const tables = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name") - .all() - .map((entry) => (entry as { name: string }).name) - expect(tables).toEqual( - expect.arrayContaining([ - 'journal_imports', - 'journal_repairs', - 'journal_rows', - 'journal_sessions', - 'journal_set_aside' - ]) - ) - expect(db.pragma('journal_mode', { simple: true })).toBe('wal') - expect(journalPragmaNumber(db, 'synchronous')).toBe(2) - expect(journalPragmaNumber(db, 'checkpoint_fullfsync')).toBe(1) - expect(journalPragmaNumber(db, 'busy_timeout')).toBe(JOURNAL_BUSY_TIMEOUT_MS) - expect(journalPragmaNumber(db, 'foreign_keys')).toBe(1) - // 2 is INCREMENTAL, which only takes on an empty file before WAL. - expect(journalPragmaNumber(db, 'auto_vacuum')).toBe(2) - expect(journalPragmaNumber(db, 'journal_size_limit')).toBe(JOURNAL_SIZE_LIMIT_BYTES) - expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) - } finally { - db.close() - } - }) - - // T5: a newer build's database opens read-only, its rows readable, and is left byte-identical. - it('opens a newer user_version read-only without touching the file', async () => { - const seeded = openJournalDatabase(dbPath).db - publishJournalSessionEpoch(seeded, SESSION, 'epoch-1') - insertJournalRow(seeded, 'session-1', epochRow(1)) - seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 5}`) - seeded.close() - const before = await digest(dbPath) - - const 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) - // 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 () => { - 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() - root = await mkdtemp(join(tmpdir(), 'orca-journal-db-')) - }) -}) - -describe('journal row statements', () => { - it('serves replay, resume and an epoch discard', () => { - const db = openJournalDatabase(dbPath).db - try { - db.exec('BEGIN IMMEDIATE') - for (let seq = 1; seq <= 5; seq += 1) { - insertJournalRow(db, 'session-1', epochRow(seq)) - } - insertJournalRow(db, 'session-2', epochRow(1)) - publishJournalSessionEpoch(db, SESSION, 'epoch-1') - publishJournalSessionEpoch(db, { sessionId: 'session-2', workspaceId: 'ws-1' }, 'epoch-1') - db.exec('COMMIT') - - expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-1') - expect(readJournalSessionEpoch(db, 'absent')).toBeNull() - expect(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 - ]) - - // Another chat in the same file is untouched by this chat's discard. - deleteJournalEpochRows(db, 'session-1', 'epoch-1') - expect(rowsOf(db, 'session-1', 'epoch-1')).toEqual([]) - expect(rowsOf(db, 'session-2', 'epoch-1')).toEqual([1]) - } finally { - db.close() - } - }) - - it('refuses a duplicate sequence inside one epoch of one chat', () => { - const db = openJournalDatabase(dbPath).db - 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, 'session-2', epochRow(1)) - } finally { - db.close() - } - }) - - it('moves the epoch pointer in place', () => { - const db = openJournalDatabase(dbPath).db - try { - publishJournalSessionEpoch(db, SESSION, 'epoch-1') - publishJournalSessionEpoch(db, SESSION, 'epoch-2') - expect(readJournalSessionEpoch(db, 'session-1')).toBe('epoch-2') - expect(db.prepare('SELECT count(*) AS total FROM journal_sessions').get()).toMatchObject({ - total: 1 - }) - } finally { - db.close() - } - }) -}) - -describe('schema creation', () => { - 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 ( - this: Database.Database, - sql: string, - options?: { simple?: boolean } - ) { - if (sql.startsWith('user_version =')) { - throw new Error('crash before the version is published') - } - return original.call(this, sql, options) - }) - - expect(() => openJournalDatabase(dbPath)).toThrow('crash before the version is published') - pragma.mockRestore() - - const inspected = new Database(dbPath) - try { - expect(inspected.pragma('user_version', { simple: true })).toBe(0) - expect( - inspected - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'journal_rows'") - .get() - ).toBeUndefined() - } finally { - inspected.close() - } - }) - - it('gives a version 3 database the chat record tables, keeping its rows', () => { - const released = new Database(dbPath) - released.exec(createJournalTablesSql()) - released.exec("INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1')") - released.pragma(`user_version = ${JOURNAL_DB_OLDEST_RELEASED_VERSION}`) - released.close() - - const { db } = openJournalDatabase(dbPath) - try { - expect(journalPragmaNumber(db, 'user_version')).toBe(JOURNAL_DB_SCHEMA_VERSION) - expect(db.prepare('SELECT session_id FROM journal_sessions').all()).toEqual([ - { session_id: 's1' } - ]) - expect(db.prepare('SELECT count(*) AS n FROM agent_session_records').get()).toEqual({ n: 0 }) - } finally { - db.close() - } - }) - - // Versions 1 and 2 were written only by unreleased builds; neither is migrated. - it.each([1, 2])('refuses a version %i database without touching the file', async (version) => { - const earlier = new Database(dbPath) - earlier.pragma('journal_mode = WAL') - earlier.exec(` -CREATE TABLE journal_rows (id INTEGER PRIMARY KEY, ts INTEGER NOT NULL, row_json TEXT NOT NULL); -CREATE TABLE journal_sessions (session_id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, - epoch TEXT NOT NULL, block INTEGER NOT NULL UNIQUE, status_json TEXT, status_seq INTEGER); -INSERT INTO journal_sessions VALUES ('s1', 'ws', 'e1', 0, NULL, NULL);`) - earlier.pragma(`user_version = ${version}`) - earlier.pragma('journal_mode = DELETE') - earlier.close() - const before = await digest(dbPath) - - expect(() => openJournalDatabase(dbPath)).toThrow(`unreleased schema ${version}`) - expect(() => openJournalDatabase(dbPath)).toThrow(JournalUnreleasedSchemaError) - - expect(await digest(dbPath)).toBe(before) - await expect(stat(`${dbPath}-wal`)).rejects.toThrow() - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.spec.ts b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.spec.ts new file mode 100644 index 00000000000..391a786dcb4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.spec.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' +import { + classifyDispatchRejection, + DISPATCH_REJECTED_WRITE_FAILED, + isWriteFailureSubmission +} 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-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalDispatchReasonBoundTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + + const HUGE = 'x'.repeat(4 * DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) + + let root: string + let clock = 1_000 + + const journals = createTrackedJournalOpener() + + async function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) + } + + async function settle(reason: string): Promise { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'msg-1', + payloadFingerprint: 'e'.repeat(64), + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'msg-1', + // A rejection's reason comes only from `agentSessionFailureWords`; an unknown's is still free + // text (an adapter's error), so it is what reaches the bound. + state: 'unknown', + reason, + fence: 1 + }) + return journal.snapshot().submissions[0]?.reason ?? null + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-dispatch-reason-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('dispatch reason bounding', () => { + it('bounds an oversized provider error before it reaches the row', async () => { + const stored = await settle(HUGE) + expect(stored).not.toBeNull() + expect(stored?.length).toBeLessThan(HUGE.length) + }) + + it('marks the clipped reason rather than truncating it silently', async () => { + const stored = await settle(HUGE) + expect(stored).toContain('[Orca: output truncated') + }) + + it('leaves a reason that already fits exactly as written', async () => { + const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`) + expect(stored).toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`) + }) + + // Head-first, not hash-replacing: the classifier prefix-matches, so a bound that kept + // the tail would render raw provider text to the user as an ordinary rejection notice. + it('keeps a clipped transport failure classifiable', async () => { + const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) + expect(stored).not.toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) + // Read as an older host wrote it: by the reason alone. + expect(isWriteFailureSubmission({ reason: stored })).toBe(true) + expect(classifyDispatchRejection({ reason: stored })).toMatchObject({ kind: 'writeFailed' }) + }) + }) +} 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 deleted file mode 100644 index 574dec492ee..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-dispatch-reason-bound.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionJournalIdentity } from '../../../shared/agent-session-journal-types' -import { - classifyDispatchRejection, - DISPATCH_REJECTED_WRITE_FAILED, - isWriteFailureSubmission -} 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-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -const HUGE = 'x'.repeat(4 * DEFAULT_JOURNAL_PAYLOAD_LIMITS.inlineHeadBytes) - -let root: string -let clock = 1_000 - -const journals = createTrackedJournalOpener() - -async function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => (clock += 1), - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -async function settle(reason: string): Promise { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'msg-1', - payloadFingerprint: 'e'.repeat(64), - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'msg-1', - // A rejection's reason comes only from `agentSessionFailureWords`; an unknown's is still free - // text (an adapter's error), so it is what reaches the bound. - state: 'unknown', - reason, - fence: 1 - }) - return journal.snapshot().submissions[0]?.reason ?? null -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-dispatch-reason-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('dispatch reason bounding', () => { - it('bounds an oversized provider error before it reaches the row', async () => { - const stored = await settle(HUGE) - expect(stored).not.toBeNull() - expect(stored?.length).toBeLessThan(HUGE.length) - }) - - it('marks the clipped reason rather than truncating it silently', async () => { - const stored = await settle(HUGE) - expect(stored).toContain('[Orca: output truncated') - }) - - it('leaves a reason that already fits exactly as written', async () => { - const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`) - expect(stored).toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: broken pipe`) - }) - - // Head-first, not hash-replacing: the classifier prefix-matches, so a bound that kept - // the tail would render raw provider text to the user as an ordinary rejection notice. - it('keeps a clipped transport failure classifiable', async () => { - const stored = await settle(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) - expect(stored).not.toBe(`${DISPATCH_REJECTED_WRITE_FAILED}: ${HUGE}`) - // Read as an older host wrote it: by the reason alone. - expect(isWriteFailureSubmission({ reason: stored })).toBe(true) - expect(classifyDispatchRejection({ reason: stored })).toMatchObject({ kind: 'writeFailed' }) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.spec.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.spec.ts new file mode 100644 index 00000000000..a4d883c8b5d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.spec.ts @@ -0,0 +1,139 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// Republishing an epoch is ONE transaction. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +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, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalEpochReplacementTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + + const PEER: AgentSessionJournalIdentity = { + ...IDENTITY, + sessionId: 'session-peer', + providerHandle: codexProviderHandle('thread-peer') + } + + let root: string + let clock = 1_000 + let database: JournalHostDatabase + const journals = createTrackedJournalOpener() + + function now(): number { + clock += 1 + 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 } + } + + function replace(input: { + items: Parameters[0]['items'] + onPublished?: (loaded: JournalLoad) => void + }): void { + replaceJournalEpoch({ + database, + identity: IDENTITY, + reason: 'legacy_import', + fence: 1, + items: input.items, + queuePause: { lifted: false, liveStop: null, reopened: false }, + now, + mintEpoch: () => `epoch-${clock}`, + onPublished: input.onPublished ?? (() => undefined) + }) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-replace-')) + clock = 1_000 + database = openTestJournalHostDatabase(root) + }) + + afterEach(async () => { + try { + database.close() + } catch { + // Already closed by the case. + } + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('journal epoch replacement', () => { + it('publishes one observable replacement', () => { + const published: JournalLoad[] = [] + + replace({ + items: [ + { identity: item(1), body: { kind: 'status', text: 'republished' }, ownerFence: 1 } + ], + onPublished: (loaded) => published.push(loaded) + }) + + expect(published).toHaveLength(1) + const epoch = readJournalSessionEpoch(database.db, IDENTITY.sessionId) + expect(epoch).toBe(published[0]?.state.epoch) + expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2) + }) + + // 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' }, ownerFence: 1 } + ]) + ], + ['a rollover', (journal: AgentSessionJournal) => journal.rollEpoch('handle_forked', 1)] + ])('discards every superseded row in the same transaction as %s', async (_name, retire) => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const peer = await journals.open({ identity: PEER, stateDirectory: root }) + const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + await journal.appendItem(item(1), { kind: 'status', text: 'old' }, scope) + await journal.appendItem(item(2), { kind: 'status', text: 'older' }, scope) + await peer.appendItem(item(1), { kind: 'status', text: 'peer' }, scope) + const before = journal.epoch + + await retire(journal) + + expect(journal.epoch).not.toBe(before) + 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.test.ts b/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts deleted file mode 100644 index c64027b9455..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-epoch-replacement.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Republishing an epoch is ONE transaction. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -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, - openTestJournalHostDatabase, - readTestJournalRows -} from './journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -const PEER: AgentSessionJournalIdentity = { - ...IDENTITY, - sessionId: 'session-peer', - providerHandle: codexProviderHandle('thread-peer') -} - -let root: string -let clock = 1_000 -let database: JournalHostDatabase -const journals = createTrackedJournalOpener() - -function now(): number { - clock += 1 - 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 } -} - -function replace(input: { - items: Parameters[0]['items'] - onPublished?: (loaded: JournalLoad) => void -}): void { - replaceJournalEpoch({ - database, - identity: IDENTITY, - reason: 'legacy_import', - fence: 1, - items: input.items, - queuePause: { lifted: false, liveStop: null, reopened: false }, - now, - mintEpoch: () => `epoch-${clock}`, - onPublished: input.onPublished ?? (() => undefined) - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-replace-')) - clock = 1_000 - database = openTestJournalHostDatabase(root) -}) - -afterEach(async () => { - try { - database.close() - } catch { - // Already closed by the case. - } - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('journal epoch replacement', () => { - it('publishes one observable replacement', () => { - const published: JournalLoad[] = [] - - replace({ - items: [{ identity: item(1), body: { kind: 'status', text: 'republished' }, ownerFence: 1 }], - onPublished: (loaded) => published.push(loaded) - }) - - expect(published).toHaveLength(1) - const epoch = readJournalSessionEpoch(database.db, IDENTITY.sessionId) - expect(epoch).toBe(published[0]?.state.epoch) - expect(readTestJournalRows(database.db, IDENTITY.sessionId, epoch ?? '')).toHaveLength(2) - }) - - // 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' }, ownerFence: 1 } - ]) - ], - ['a rollover', (journal: AgentSessionJournal) => journal.rollEpoch('handle_forked', 1)] - ])('discards every superseded row in the same transaction as %s', async (_name, retire) => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const peer = await journals.open({ identity: PEER, stateDirectory: root }) - const scope = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - await journal.appendItem(item(1), { kind: 'status', text: 'old' }, scope) - await journal.appendItem(item(2), { kind: 'status', text: 'older' }, scope) - await peer.appendItem(item(1), { kind: 'status', text: 'peer' }, scope) - const before = journal.epoch - - await retire(journal) - - expect(journal.epoch).not.toBe(before) - 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-host-database.spec.ts b/src/main/native-chat/agent-session-journal/journal-host-database.spec.ts new file mode 100644 index 00000000000..e93f52653fe --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-host-database.spec.ts @@ -0,0 +1,271 @@ +// The one connection every chat's writes share: its transactions, its busy handling, and the order +// in which it closes at quit. + +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { spawnProcess } from '@orca/process-host' +import { tearDownRuntime } from '../../runtime/structured-agent-session-runtime-teardown' +import { journalDatabasePath } from './journal-host-database' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + openTestJournalHostDatabase, + readTestJournalRows +} from './journal-host-database-test-support' +import type Database from '../../sqlite/sync-database' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalHostDatabaseTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + + const OTHER: AgentSessionJournalIdentity = { + ...IDENTITY, + sessionId: 'session-2', + providerHandle: codexProviderHandle('thread-2') + } + + const SUBMISSION = { + clientMessageId: 'msg-1', + payloadFingerprint: 'e'.repeat(64), + body: { + kind: 'message' as const, + role: 'user' as const, + blocks: [{ type: 'text' as const, text: 'hi' }] + }, + fence: 1 + } + + let root: string + const journals = createTrackedJournalOpener() + + function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } + } + + /** The next row insert breaks a deferred foreign key, so its transaction's COMMIT fails and SQLite + * leaves the transaction open, as it may for a COMMIT that fails on its own. */ + function failCommits(db: Database.Database): () => void { + db.exec(`CREATE TEMP TABLE commit_probe_parent (id INTEGER PRIMARY KEY); +CREATE TEMP TABLE commit_probe_child ( + parent INTEGER REFERENCES commit_probe_parent (id) DEFERRABLE INITIALLY DEFERRED +); +CREATE TEMP TRIGGER commit_probe AFTER INSERT ON main.journal_rows +BEGIN INSERT INTO commit_probe_child (parent) VALUES (1); END`) + return () => db.exec('DROP TRIGGER temp.commit_probe') + } + + function text(value: string) { + return { + kind: 'message' as const, + role: 'assistant' as const, + blocks: [{ type: 'text' as const, text: value }] + } + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-host-')) + }) + + afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('the shared connection', () => { + // T8: another connection holding the write lock makes an append wait, not fail. + it('waits out another writer instead of failing the append', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const holder = spawnProcess({ + program: process.execPath, + args: [ + '-e', + `const { DatabaseSync } = require('node:sqlite') +const db = new DatabaseSync(process.argv[1]) +db.exec('BEGIN IMMEDIATE') +process.stdout.write('holding') +setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, + journalDatabasePath(root) + ], + timeoutMs: 30_000 + }) + const exited = new Promise((resolve) => holder.once('exit', resolve)) + await new Promise((resolve) => holder.stdout.once('data', () => resolve())) + + await expect( + journal.appendItem(item(1), text('after the wait'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).resolves.toBeDefined() + expect(await exited).toBe(0) + }) + + // The #22993 companion: a transaction that cannot begin fails that write alone. + it('fails only the write whose transaction could not begin', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const exec = database.db.exec.bind(database.db) + const busy = Object.assign(new Error('database is locked'), { + code: 'ERR_SQLITE_ERROR', + errcode: 5 + }) + vi.spyOn(database.db, 'exec').mockImplementationOnce(() => { + throw busy + }) + + await expect( + journal.appendItem(item(1), text('refused'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toBe(busy) + vi.mocked(database.db.exec).mockImplementation(exec) + await expect( + journal.appendItem(item(2), text('next'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).resolves.toBeDefined() + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([text('next')]) + }) + + it('frees the connection after a failed COMMIT: nothing adopted, published or acknowledged', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const other = await journals.open({ identity: OTHER, stateDirectory: root }) + const connection = openTestJournalHostDatabase(root).db + const published = vi.fn() + journal.observeCommits(published) + const cursor = journal.cursor() + const rows = () => + readTestJournalRows(connection, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) + const rowsBefore = rows() + const stopFailing = failCommits(connection) + + await expect(journal.appendSubmission(SUBMISSION)).rejects.toThrow( + 'FOREIGN KEY constraint failed' + ) + expect(connection.isTransaction).toBe(false) + stopFailing() + + expect(published).not.toHaveBeenCalled() + expect(journal.cursor()).toEqual(cursor) + expect(journal.submissions()).toEqual([]) + expect(rows()).toEqual(rowsBefore) + await expect( + other.appendItem(item(1), text('another chat'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).resolves.toBeDefined() + }) + + it('refuses every chat while a failed transaction will not roll back, then serves them', async () => { + const other = await journals.open({ identity: OTHER, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const connection = database.db + const exec = connection.exec.bind(connection) + let rollbackFails = true + vi.spyOn(connection, 'exec').mockImplementation((sql) => { + if (sql === 'ROLLBACK' && rollbackFails) { + throw Object.assign(new Error('disk I/O error'), { + code: 'ERR_SQLITE_ERROR', + errcode: 10 + }) + } + exec(sql) + }) + vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const stopFailing = failCommits(connection) + + // Its caller gets the COMMIT's own error. + expect(() => + database.transaction((db) => + db + .prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ) + .run('unknown', 'epoch-unknown', 1, 1, '{}') + ) + ).toThrow('FOREIGN KEY constraint failed') + expect(connection.isTransaction).toBe(true) + await expect( + other.appendItem(item(1), text('refused'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalUnavailable' } + } + }) + + rollbackFails = false + expect(database.db.isTransaction).toBe(false) + stopFailing() + await expect( + other.appendItem(item(1), text('served'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ).resolves.toBeDefined() + expect(readTestJournalRows(connection, OTHER.sessionId, other.epoch)).not.toHaveLength(0) + }) + + it('refuses a transaction body that awaits', () => { + const database = openTestJournalHostDatabase(root) + expect(() => database.transaction(async () => undefined)).toThrow('must not await') + // Rolled back: the connection is free for the next transaction. + expect(database.db.isTransaction).toBe(false) + }) + }) + + describe('quit', () => { + // T-quit-drain: a sink write still in flight while the host flushes lands before the one + // connection closes, because the connection closes last. + it('closes the one connection only after the host has flushed what was in flight', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const database = openTestJournalHostDatabase(root) + const installed = { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host. + host: { + stopDelivery: () => {}, + flushAllStreamedEvents: async () => { + // A child's last row, delivered while quit is draining its sink. + await new Promise((resolve) => setTimeout(resolve, 10)) + await journal.appendItem(item(1), text('written during quit'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + } as never, + adapter: { closeAll: async () => undefined }, + journalDatabase: database, + waitForRecovery: async () => undefined + } + + await tearDownRuntime(installed, 'quit') + + expect(database.isClosed).toBe(true) + closeTestJournalHostDatabases() + const reopened = openTestJournalHostDatabase(root) + expect( + readTestJournalRows(reopened.db, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) + ).toEqual([1, 2]) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts b/src/main/native-chat/agent-session-journal/journal-host-database.test.ts deleted file mode 100644 index e4bc88384b6..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-host-database.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -// The one connection every chat's writes share: its transactions, its busy handling, and the order -// in which it closes at quit. - -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { spawnProcess } from '@orca/process-host' -import { tearDownRuntime } from '../../runtime/structured-agent-session-runtime-teardown' -import { journalDatabasePath } from './journal-host-database' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener, - openTestJournalHostDatabase, - readTestJournalRows -} from './journal-host-database-test-support' -import type Database from '../../sqlite/sync-database' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'local', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -const OTHER: AgentSessionJournalIdentity = { - ...IDENTITY, - sessionId: 'session-2', - providerHandle: codexProviderHandle('thread-2') -} - -const SUBMISSION = { - clientMessageId: 'msg-1', - payloadFingerprint: 'e'.repeat(64), - body: { - kind: 'message' as const, - role: 'user' as const, - blocks: [{ type: 'text' as const, text: 'hi' }] - }, - fence: 1 -} - -let root: string -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -/** The next row insert breaks a deferred foreign key, so its transaction's COMMIT fails and SQLite - * leaves the transaction open, as it may for a COMMIT that fails on its own. */ -function failCommits(db: Database.Database): () => void { - db.exec(`CREATE TEMP TABLE commit_probe_parent (id INTEGER PRIMARY KEY); -CREATE TEMP TABLE commit_probe_child ( - parent INTEGER REFERENCES commit_probe_parent (id) DEFERRABLE INITIALLY DEFERRED -); -CREATE TEMP TRIGGER commit_probe AFTER INSERT ON main.journal_rows -BEGIN INSERT INTO commit_probe_child (parent) VALUES (1); END`) - return () => db.exec('DROP TRIGGER temp.commit_probe') -} - -function text(value: string) { - return { - kind: 'message' as const, - role: 'assistant' as const, - blocks: [{ type: 'text' as const, text: value }] - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-host-')) -}) - -afterEach(async () => { - vi.restoreAllMocks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('the shared connection', () => { - // T8: another connection holding the write lock makes an append wait, not fail. - it('waits out another writer instead of failing the append', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const holder = spawnProcess({ - program: process.execPath, - args: [ - '-e', - `const { DatabaseSync } = require('node:sqlite') -const db = new DatabaseSync(process.argv[1]) -db.exec('BEGIN IMMEDIATE') -process.stdout.write('holding') -setTimeout(() => { db.exec('COMMIT'); db.close() }, 200)`, - journalDatabasePath(root) - ], - timeoutMs: 30_000 - }) - const exited = new Promise((resolve) => holder.once('exit', resolve)) - await new Promise((resolve) => holder.stdout.once('data', () => resolve())) - - await expect( - journal.appendItem(item(1), text('after the wait'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ).resolves.toBeDefined() - expect(await exited).toBe(0) - }) - - // The #22993 companion: a transaction that cannot begin fails that write alone. - it('fails only the write whose transaction could not begin', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const database = openTestJournalHostDatabase(root) - const exec = database.db.exec.bind(database.db) - const busy = Object.assign(new Error('database is locked'), { - code: 'ERR_SQLITE_ERROR', - errcode: 5 - }) - vi.spyOn(database.db, 'exec').mockImplementationOnce(() => { - throw busy - }) - - await expect( - journal.appendItem(item(1), text('refused'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ).rejects.toBe(busy) - vi.mocked(database.db.exec).mockImplementation(exec) - await expect( - journal.appendItem(item(2), text('next'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).resolves.toBeDefined() - expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([text('next')]) - }) - - it('frees the connection after a failed COMMIT: nothing adopted, published or acknowledged', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const other = await journals.open({ identity: OTHER, stateDirectory: root }) - const connection = openTestJournalHostDatabase(root).db - const published = vi.fn() - journal.observeCommits(published) - const cursor = journal.cursor() - const rows = () => - readTestJournalRows(connection, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) - const rowsBefore = rows() - const stopFailing = failCommits(connection) - - await expect(journal.appendSubmission(SUBMISSION)).rejects.toThrow( - 'FOREIGN KEY constraint failed' - ) - expect(connection.isTransaction).toBe(false) - stopFailing() - - expect(published).not.toHaveBeenCalled() - expect(journal.cursor()).toEqual(cursor) - expect(journal.submissions()).toEqual([]) - expect(rows()).toEqual(rowsBefore) - await expect( - other.appendItem(item(1), text('another chat'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ).resolves.toBeDefined() - }) - - it('refuses every chat while a failed transaction will not roll back, then serves them', async () => { - const other = await journals.open({ identity: OTHER, stateDirectory: root }) - const database = openTestJournalHostDatabase(root) - const connection = database.db - const exec = connection.exec.bind(connection) - let rollbackFails = true - vi.spyOn(connection, 'exec').mockImplementation((sql) => { - if (sql === 'ROLLBACK' && rollbackFails) { - throw Object.assign(new Error('disk I/O error'), { code: 'ERR_SQLITE_ERROR', errcode: 10 }) - } - exec(sql) - }) - vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const stopFailing = failCommits(connection) - - // Its caller gets the COMMIT's own error. - expect(() => - database.transaction((db) => - db - .prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ) - .run('unknown', 'epoch-unknown', 1, 1, '{}') - ) - ).toThrow('FOREIGN KEY constraint failed') - expect(connection.isTransaction).toBe(true) - await expect( - other.appendItem(item(1), text('refused'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ).rejects.toMatchObject({ - refusal: { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalUnavailable' } - } - }) - - rollbackFails = false - expect(database.db.isTransaction).toBe(false) - stopFailing() - await expect( - other.appendItem(item(1), text('served'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).resolves.toBeDefined() - expect(readTestJournalRows(connection, OTHER.sessionId, other.epoch)).not.toHaveLength(0) - }) - - it('refuses a transaction body that awaits', () => { - const database = openTestJournalHostDatabase(root) - expect(() => database.transaction(async () => undefined)).toThrow('must not await') - // Rolled back: the connection is free for the next transaction. - expect(database.db.isTransaction).toBe(false) - }) -}) - -describe('quit', () => { - // T-quit-drain: a sink write still in flight while the host flushes lands before the one - // connection closes, because the connection closes last. - it('closes the one connection only after the host has flushed what was in flight', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const database = openTestJournalHostDatabase(root) - const installed = { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: teardown calls only `stopDelivery` and `flushAllStreamedEvents` on the host. - host: { - stopDelivery: () => {}, - flushAllStreamedEvents: async () => { - // A child's last row, delivered while quit is draining its sink. - await new Promise((resolve) => setTimeout(resolve, 10)) - await journal.appendItem(item(1), text('written during quit'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - } as never, - adapter: { closeAll: async () => undefined }, - journalDatabase: database, - waitForRecovery: async () => undefined - } - - await tearDownRuntime(installed, 'quit') - - expect(database.isClosed).toBe(true) - closeTestJournalHostDatabases() - const reopened = openTestJournalHostDatabase(root) - expect( - readTestJournalRows(reopened.db, IDENTITY.sessionId, journal.epoch).map((row) => row.seq) - ).toEqual([1, 2]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.spec.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.spec.ts new file mode 100644 index 00000000000..93854adfdf6 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-provenance.spec.ts @@ -0,0 +1,130 @@ +// Execution provenance (`JournalItemRow.ownerFence`): the generation whose execution produced an +// item, kept apart from each row's writer fence. Creation states it; every revision keeps it. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { retainedRowReplacement } from '../agent-session-wire/structured-rewind-retained-host-rows' +import type { JournalHostDatabase } from './journal-host-database' +import { + createTrackedJournalOpener, + liveTestJournalRows, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import type { AgentSessionJournal } from './journal-store' + +export function registerJournalItemProvenanceTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + const SCOPE = AGENT_JOURNAL_THREAD_SCOPE + const TURN: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal: 1 + } + const TURN_ID = agentJournalItemKey(TURN) + const RUNNING: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } + const SETTLED: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'interrupted' } + + let root: string + let database: JournalHostDatabase + const journals = createTrackedJournalOpener() + + function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } + } + + type StoredItemWrite = { kind?: string; ownerFence?: number; mutations?: StoredItemWrite[] } + + /** Each stored item write's stated provenance, in write order, a lifecycle batch's included. */ + function storedOwnerFences(): (number | undefined)[] { + return liveTestJournalRows(database.db, IDENTITY.sessionId).flatMap((row) => { + const parsed: StoredItemWrite = JSON.parse(row.rowJson) + const writes = parsed.kind === 'lifecycle-batch' ? (parsed.mutations ?? []) : [parsed] + return writes.flatMap((write) => (write.kind === 'item' ? [write.ownerFence] : [])) + }) + } + + async function open(): Promise { + return journals.open({ identity: IDENTITY, stateDirectory: root }) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-provenance-')) + database = openTestJournalHostDatabase(root) + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('execution provenance', () => { + it("records the creating observation's owner, every revision keeps it, and a reopen reads it", async () => { + const journal = await open() + await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE }) + // A newer run's provider revising it (a start's context-usage report on the newest saved turn). + await journal.appendItem(TURN, RUNNING, { fence: 5, ownerFence: 5, turnScope: SCOPE }) + // Host bookkeeping under a later writer fence states no owner of its own. + await journal.appendItem(TURN, SETTLED, { fence: 5, turnScope: SCOPE }) + // A first write with no stated owner is its writer generation's. + await journal.appendItem( + item(2), + { kind: 'status', text: 'note' }, + { fence: 6, turnScope: SCOPE } + ) + + expect(journal.itemFence(TURN_ID)).toBe(3) + expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(6) + // Every row states it: none leaves the reader to guess from the writer fence. + expect(storedOwnerFences()).toEqual([3, 3, 3, 6]) + await journals.closeAll() + expect((await open()).itemFence(TURN_ID)).toBe(3) + }) + + it('a lifecycle batch states it on each item: the batch owner, else the kept value', async () => { + const journal = await open() + await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE }) + // A provider batch at the owner's fence creates one item. + await journal.appendLifecycleBatch({ + settlementId: 'provider', + fence: 3, + ownerFence: 3, + mutations: [{ kind: 'item', identity: item(2), body: RUNNING, turnScope: SCOPE }] + }) + // A settlement at a later fence revises the first: it keeps that item's owner. + await journal.appendLifecycleBatch({ + settlementId: 'settlement', + fence: 4, + recovered: true, + mutations: [{ kind: 'item', identity: TURN, body: SETTLED, turnScope: SCOPE }] + }) + + expect(journal.itemFence(TURN_ID)).toBe(2) + expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(3) + expect(storedOwnerFences()).toEqual([2, 3, 2]) + }) + + it("a rewind's retained row carries the old fold's provenance; an older record's is never current", () => { + const row = { itemId: TURN_ID, body: RUNNING, observedAt: 1, ownerFence: 2 } + expect(retainedRowReplacement(row).ownerFence).toBe(2) + const { ownerFence: _stated, ...older } = row + expect(retainedRowReplacement(older).ownerFence).toBe(0) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts b/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts deleted file mode 100644 index 8e94ef30ac9..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-item-provenance.test.ts +++ /dev/null @@ -1,128 +0,0 @@ -// Execution provenance (`JournalItemRow.ownerFence`): the generation whose execution produced an -// item, kept apart from each row's writer fence. Creation states it; every revision keeps it. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemBody, - type AgentJournalItemIdentity, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { retainedRowReplacement } from '../agent-session-wire/structured-rewind-retained-host-rows' -import type { JournalHostDatabase } from './journal-host-database' -import { - createTrackedJournalOpener, - liveTestJournalRows, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import type { AgentSessionJournal } from './journal-store' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} -const SCOPE = AGENT_JOURNAL_THREAD_SCOPE -const TURN: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'thread-1', - turnId: 'turn-1', - ordinal: 1 -} -const TURN_ID = agentJournalItemKey(TURN) -const RUNNING: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'running' } -const SETTLED: AgentJournalItemBody = { kind: 'turn', turnId: 'turn-1', state: 'interrupted' } - -let root: string -let database: JournalHostDatabase -const journals = createTrackedJournalOpener() - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -type StoredItemWrite = { kind?: string; ownerFence?: number; mutations?: StoredItemWrite[] } - -/** Each stored item write's stated provenance, in write order, a lifecycle batch's included. */ -function storedOwnerFences(): (number | undefined)[] { - return liveTestJournalRows(database.db, IDENTITY.sessionId).flatMap((row) => { - const parsed: StoredItemWrite = JSON.parse(row.rowJson) - const writes = parsed.kind === 'lifecycle-batch' ? (parsed.mutations ?? []) : [parsed] - return writes.flatMap((write) => (write.kind === 'item' ? [write.ownerFence] : [])) - }) -} - -async function open(): Promise { - return journals.open({ identity: IDENTITY, stateDirectory: root }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-provenance-')) - database = openTestJournalHostDatabase(root) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('execution provenance', () => { - it("records the creating observation's owner, every revision keeps it, and a reopen reads it", async () => { - const journal = await open() - await journal.appendItem(TURN, RUNNING, { fence: 4, ownerFence: 3, turnScope: SCOPE }) - // A newer run's provider revising it (a start's context-usage report on the newest saved turn). - await journal.appendItem(TURN, RUNNING, { fence: 5, ownerFence: 5, turnScope: SCOPE }) - // Host bookkeeping under a later writer fence states no owner of its own. - await journal.appendItem(TURN, SETTLED, { fence: 5, turnScope: SCOPE }) - // A first write with no stated owner is its writer generation's. - await journal.appendItem( - item(2), - { kind: 'status', text: 'note' }, - { fence: 6, turnScope: SCOPE } - ) - - expect(journal.itemFence(TURN_ID)).toBe(3) - expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(6) - // Every row states it: none leaves the reader to guess from the writer fence. - expect(storedOwnerFences()).toEqual([3, 3, 3, 6]) - await journals.closeAll() - expect((await open()).itemFence(TURN_ID)).toBe(3) - }) - - it('a lifecycle batch states it on each item: the batch owner, else the kept value', async () => { - const journal = await open() - await journal.appendItem(TURN, RUNNING, { fence: 2, ownerFence: 2, turnScope: SCOPE }) - // A provider batch at the owner's fence creates one item. - await journal.appendLifecycleBatch({ - settlementId: 'provider', - fence: 3, - ownerFence: 3, - mutations: [{ kind: 'item', identity: item(2), body: RUNNING, turnScope: SCOPE }] - }) - // A settlement at a later fence revises the first: it keeps that item's owner. - await journal.appendLifecycleBatch({ - settlementId: 'settlement', - fence: 4, - recovered: true, - mutations: [{ kind: 'item', identity: TURN, body: SETTLED, turnScope: SCOPE }] - }) - - expect(journal.itemFence(TURN_ID)).toBe(2) - expect(journal.itemFence(agentJournalItemKey(item(2)))).toBe(3) - expect(storedOwnerFences()).toEqual([2, 3, 2]) - }) - - it("a rewind's retained row carries the old fold's provenance; an older record's is never current", () => { - const row = { itemId: TURN_ID, body: RUNNING, observedAt: 1, ownerFence: 2 } - expect(retainedRowReplacement(row).ownerFence).toBe(2) - const { ownerFence: _stated, ...older } = row - expect(retainedRowReplacement(older).ownerFence).toBe(0) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.spec.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.spec.ts new file mode 100644 index 00000000000..1c7b2f5be36 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.spec.ts @@ -0,0 +1,184 @@ +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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' +import { createJournalReducerState } from './journal-reducer' +import type { JournalLifecycleMutationInput } from './journal-row-builders' +import { parseJournalRow, type JournalRow } from './journal-row-schema' +import { + createTrackedJournalOpener, + liveTestJournalRows, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import type { AgentSessionJournal } from './journal-store' + +export function registerJournalLifecycleBatchAppenderTests(): void { + function item(itemId: string, text = 'saved'): JournalLifecycleMutationInput { + return { + kind: 'item', + itemId, + body: { kind: 'status', text }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + } + + const SESSION = 'repeated-items' + const journals = createTrackedJournalOpener() + let root: string + let journal: AgentSessionJournal + const options = () => ({ + identity: { + sessionId: SESSION, + workspaceId: 'folder', + hostId: 'host', + agent: 'codex' as const, + providerHandle: codexProviderHandle('thread') + }, + stateDirectory: root, + now: () => 1_000 + }) + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-repeated-items-')) + journal = await journals.open(options()) + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + it.each(['within chunk', 'across chunks', 'oversized first', 'oversized last'] as const)( + 'folds repeated direct mutations in order %s through SQLite', + async (placement) => { + const oversized = 'x'.repeat(1_500_001) + const first = placement === 'oversized first' ? oversized : 'first' + const last = placement === 'oversized last' ? oversized : 'last' + const mutations = [ + item('saved', first), + ...(placement === 'across chunks' + ? Array.from({ length: 199 }, (_, index) => item(`filler-${index}`)) + : []), + item('saved', last) + ] + const commits = vi.fn() + journal.observeCommits(commits) + const input = { settlementId: 'settlement', fence: 8, mutations } + const before = journal.cursor().sequence + await journal.appendLifecycleBatch(input) + expect(commits).toHaveBeenCalledTimes(1) + const { db } = openTestJournalHostDatabase(root) + const rows = liveTestJournalRows(db, SESSION).flatMap((stored) => { + const parsed = parseJournalRow(stored.rowJson) + if (!parsed.ok) { + throw new Error('test row was not admitted') + } + return parsed.row.seq > before ? [parsed.row] : [] + }) + const written = rows.flatMap((row) => + row.kind === 'lifecycle-batch' ? row.mutations : row.kind === 'item' ? [row] : [] + ) + expect(written).toHaveLength(mutations.length) + expect(written.filter((mutation) => mutation.itemId === 'saved')).toMatchObject([ + { revision: 1, body: { text: first } }, + { revision: 2, body: { text: last } } + ]) + expect(rows).toHaveLength(placement === 'within chunk' ? 1 : 2) + expect(journal.snapshot().items).toHaveLength(mutations.length - 1) + expect(journal.itemBody('saved')).toEqual({ kind: 'status', text: last }) + const settled = journal.snapshot() + await journal.appendLifecycleBatch(input) + expect(journal.snapshot()).toEqual(settled) + expect(commits).toHaveBeenCalledTimes(1) + await journal.close() + journal = await journals.open(options()) + expect(journal.snapshot()).toEqual(settled) + } + ) + + it('does not skip an oversized repeat matching the saved body after an earlier planned change', async () => { + const saved = { kind: 'status' as const, text: 'x'.repeat(1_500_001) } + await journal.appendLifecycleBatch({ + settlementId: 'seed-saved', + fence: 8, + mutations: [item('saved', saved.text)] + }) + await journal.appendLifecycleBatch({ + settlementId: 'restore-saved', + fence: 8, + mutations: [item('saved', 'changed'), item('saved', saved.text)] + }) + expect(journal.snapshot().items).toMatchObject([{ itemId: 'saved', revision: 3, body: saved }]) + }) + + it('advances alias and tombstone repeats across rows before re-adding the item', async () => { + const identity = { provider: 'codex' as const, threadId: 'thread', turnId: 'turn', ordinal: 0 } + await journal.appendSubmission({ + clientMessageId: 'saved', + payloadFingerprint: 'fp', + fence: 8, + body: { kind: 'message', role: 'user', blocks: [] } + }) + await journal.resolveDispatch({ + clientMessageId: 'saved', + state: 'accepted', + providerIdentity: identity, + fence: 8 + }) + await journal.appendItem( + { provider: 'orca', clientMessageId: 'saved' }, + { kind: 'status', text: 'seeded' }, + { fence: 8, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendLifecycleBatch({ + settlementId: 'alias-repeats', + fence: 8, + mutations: [ + item('orca:saved', 'first'), + ...Array.from({ length: 199 }, (_, index) => item(`filler-${index}`)), + { kind: 'tombstone', identity }, + item('orca:saved', 'last') + ] + }) + expect(journal.snapshot().items.find((row) => row.itemId === 'orca:saved')).toMatchObject({ + revision: 4, + body: { text: 'last' } + }) + }) + + describe('resolved lifecycle planning', () => { + it.each(['chunks', 'oversized', 'alias'] as const)( + 'rejects repeated items across %s before any row is written', + (repeatedAs) => { + const state = createJournalReducerState('session', 'epoch') + state.aliases.set('alias', 'saved') + const rows: JournalRow[] = [] + const appender = new JournalLifecycleBatchAppender({ + state: () => state, + cursor: () => ({ epoch: state.epoch, sequence: state.lastSequence }), + enqueueRows: async (plan) => { + const built = plan().map((build, index) => build(index + 1, 1_000)) + rows.push(...built) + return built + } + }) + const mutations = [ + item('saved', repeatedAs === 'oversized' ? 'x'.repeat(1_500_001) : 'first'), + ...Array.from({ length: 200 }, (_, index) => item(`filler-${index}`)), + repeatedAs === 'alias' + ? { kind: 'tombstone' as const, itemId: 'alias' } + : item('saved', 'second') + ] + expect(() => + appender.planResolved({ settlementId: 'settlement', fence: 8, resolve: () => mutations }) + ).toThrow('journal_resolved_lifecycle_batch_names_item_twice') + expect(rows).toEqual([]) + expect(state.items.size).toBe(0) + expect(state.lastSequence).toBe(0) + } + ) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.test.ts b/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.test.ts deleted file mode 100644 index d5f91427231..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-lifecycle-batch-appender.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { JournalLifecycleBatchAppender } from './journal-lifecycle-batch-appender' -import { createJournalReducerState } from './journal-reducer' -import type { JournalLifecycleMutationInput } from './journal-row-builders' -import { parseJournalRow, type JournalRow } from './journal-row-schema' -import { - createTrackedJournalOpener, - liveTestJournalRows, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import type { AgentSessionJournal } from './journal-store' - -function item(itemId: string, text = 'saved'): JournalLifecycleMutationInput { - return { - kind: 'item', - itemId, - body: { kind: 'status', text }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } -} - -const SESSION = 'repeated-items' -const journals = createTrackedJournalOpener() -let root: string -let journal: AgentSessionJournal -const options = () => ({ - identity: { - sessionId: SESSION, - workspaceId: 'folder', - hostId: 'host', - agent: 'codex' as const, - providerHandle: codexProviderHandle('thread') - }, - stateDirectory: root, - now: () => 1_000 -}) - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-repeated-items-')) - journal = await journals.open(options()) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -it.each(['within chunk', 'across chunks', 'oversized first', 'oversized last'] as const)( - 'folds repeated direct mutations in order %s through SQLite', - async (placement) => { - const oversized = 'x'.repeat(1_500_001) - const first = placement === 'oversized first' ? oversized : 'first' - const last = placement === 'oversized last' ? oversized : 'last' - const mutations = [ - item('saved', first), - ...(placement === 'across chunks' - ? Array.from({ length: 199 }, (_, index) => item(`filler-${index}`)) - : []), - item('saved', last) - ] - const commits = vi.fn() - journal.observeCommits(commits) - const input = { settlementId: 'settlement', fence: 8, mutations } - const before = journal.cursor().sequence - await journal.appendLifecycleBatch(input) - expect(commits).toHaveBeenCalledTimes(1) - const { db } = openTestJournalHostDatabase(root) - const rows = liveTestJournalRows(db, SESSION).flatMap((stored) => { - const parsed = parseJournalRow(stored.rowJson) - if (!parsed.ok) { - throw new Error('test row was not admitted') - } - return parsed.row.seq > before ? [parsed.row] : [] - }) - const written = rows.flatMap((row) => - row.kind === 'lifecycle-batch' ? row.mutations : row.kind === 'item' ? [row] : [] - ) - expect(written).toHaveLength(mutations.length) - expect(written.filter((mutation) => mutation.itemId === 'saved')).toMatchObject([ - { revision: 1, body: { text: first } }, - { revision: 2, body: { text: last } } - ]) - expect(rows).toHaveLength(placement === 'within chunk' ? 1 : 2) - expect(journal.snapshot().items).toHaveLength(mutations.length - 1) - expect(journal.itemBody('saved')).toEqual({ kind: 'status', text: last }) - const settled = journal.snapshot() - await journal.appendLifecycleBatch(input) - expect(journal.snapshot()).toEqual(settled) - expect(commits).toHaveBeenCalledTimes(1) - await journal.close() - journal = await journals.open(options()) - expect(journal.snapshot()).toEqual(settled) - } -) - -it('does not skip an oversized repeat matching the saved body after an earlier planned change', async () => { - const saved = { kind: 'status' as const, text: 'x'.repeat(1_500_001) } - await journal.appendLifecycleBatch({ - settlementId: 'seed-saved', - fence: 8, - mutations: [item('saved', saved.text)] - }) - await journal.appendLifecycleBatch({ - settlementId: 'restore-saved', - fence: 8, - mutations: [item('saved', 'changed'), item('saved', saved.text)] - }) - expect(journal.snapshot().items).toMatchObject([{ itemId: 'saved', revision: 3, body: saved }]) -}) - -it('advances alias and tombstone repeats across rows before re-adding the item', async () => { - const identity = { provider: 'codex' as const, threadId: 'thread', turnId: 'turn', ordinal: 0 } - await journal.appendSubmission({ - clientMessageId: 'saved', - payloadFingerprint: 'fp', - fence: 8, - body: { kind: 'message', role: 'user', blocks: [] } - }) - await journal.resolveDispatch({ - clientMessageId: 'saved', - state: 'accepted', - providerIdentity: identity, - fence: 8 - }) - await journal.appendItem( - { provider: 'orca', clientMessageId: 'saved' }, - { kind: 'status', text: 'seeded' }, - { fence: 8, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.appendLifecycleBatch({ - settlementId: 'alias-repeats', - fence: 8, - mutations: [ - item('orca:saved', 'first'), - ...Array.from({ length: 199 }, (_, index) => item(`filler-${index}`)), - { kind: 'tombstone', identity }, - item('orca:saved', 'last') - ] - }) - expect(journal.snapshot().items.find((row) => row.itemId === 'orca:saved')).toMatchObject({ - revision: 4, - body: { text: 'last' } - }) -}) - -describe('resolved lifecycle planning', () => { - it.each(['chunks', 'oversized', 'alias'] as const)( - 'rejects repeated items across %s before any row is written', - (repeatedAs) => { - const state = createJournalReducerState('session', 'epoch') - state.aliases.set('alias', 'saved') - const rows: JournalRow[] = [] - const appender = new JournalLifecycleBatchAppender({ - state: () => state, - cursor: () => ({ epoch: state.epoch, sequence: state.lastSequence }), - enqueueRows: async (plan) => { - const built = plan().map((build, index) => build(index + 1, 1_000)) - rows.push(...built) - return built - } - }) - const mutations = [ - item('saved', repeatedAs === 'oversized' ? 'x'.repeat(1_500_001) : 'first'), - ...Array.from({ length: 200 }, (_, index) => item(`filler-${index}`)), - repeatedAs === 'alias' - ? { kind: 'tombstone' as const, itemId: 'alias' } - : item('saved', 'second') - ] - expect(() => - appender.planResolved({ settlementId: 'settlement', fence: 8, resolve: () => mutations }) - ).toThrow('journal_resolved_lifecycle_batch_names_item_twice') - expect(rows).toEqual([]) - expect(state.items.size).toBe(0) - expect(state.lastSequence).toBe(0) - } - ) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-pi-context-import.spec.ts b/src/main/native-chat/agent-session-journal/journal-pi-context-import.spec.ts new file mode 100644 index 00000000000..c99edd2f99d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-pi-context-import.spec.ts @@ -0,0 +1,100 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + importLegacyTranscriptIntoJournal, + prepareLegacyTranscriptImport +} from './journal-legacy-import' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { PI_CONTEXT_FIXTURES } from './journal-pi-context-fixtures' + +export function registerJournalPiContextImportTests(): void { + let root: string + const journals = createTrackedJournalOpener() + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-pi-context-')) + }) + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + async function prepare(entries: unknown[], version = 3) { + const filePath = join(root, 'session.jsonl') + const header = { type: 'session', id: 'session', version, cwd: root } + // Malformed records do not contribute to Pi's v1 entry indexes. + await writeFile( + filePath, + [JSON.stringify(header), '{bad json', ...entries.map((entry) => JSON.stringify(entry))].join( + '\n' + ) + ) + const result = await prepareLegacyTranscriptImport({ + agent: 'pi', + sessionId: 'session', + options: { filePath } + }) + if (!result.ok) { + throw new Error(result.error) + } + return result.items + } + + describe('saved Pi context import', () => { + it.each(PI_CONTEXT_FIXTURES)('$name', async (fixture) => { + const items = await prepare(fixture.entries, 'version' in fixture ? fixture.version : 3) + expect( + items.map((item) => + item.body.kind === 'message' + ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) + : [] + ) + ).toEqual(fixture.texts.map((text) => [text])) + }) + + it('persists compaction and branch summaries in the adopted journal across reopen', async () => { + const fixture = PI_CONTEXT_FIXTURES[0] + if (!fixture) { + throw new Error('missing compaction fixture') + } + const expected = await prepare([ + ...fixture.entries, + { + type: 'branch_summary', + id: 'branch', + parentId: 'later', + fromId: 'discarded', + summary: 'Saved branch details.' + } + ]) + const filePath = join(root, 'session.jsonl') + const options = { + stateDirectory: join(root, 'journal'), + identity: { + sessionId: 'native-chat', + workspaceId: 'workspace', + hostId: 'host', + agent: 'pi' as const, + providerHandle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: filePath } + } + } + const journal = await journals.open(options) + expect( + await importLegacyTranscriptIntoJournal({ + journal, + agent: 'pi', + sessionId: 'session', + fence: 1, + options: { filePath } + }) + ).toMatchObject({ ok: true, imported: 4, replaced: true }) + await journal.close() + const reopened = await journals.open(options) + expect(reopened.snapshot().items.map((item) => item.body)).toEqual( + expected.map((item) => item.body) + ) + expect(JSON.stringify(reopened.snapshot().items)).toContain('Saved branch details.') + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-pi-context-import.test.ts b/src/main/native-chat/agent-session-journal/journal-pi-context-import.test.ts deleted file mode 100644 index a1450e923f3..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-pi-context-import.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - importLegacyTranscriptIntoJournal, - prepareLegacyTranscriptImport -} from './journal-legacy-import' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { PI_CONTEXT_FIXTURES } from './journal-pi-context-fixtures' - -let root: string -const journals = createTrackedJournalOpener() -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-pi-context-')) -}) -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -async function prepare(entries: unknown[], version = 3) { - const filePath = join(root, 'session.jsonl') - const header = { type: 'session', id: 'session', version, cwd: root } - // Malformed records do not contribute to Pi's v1 entry indexes. - await writeFile( - filePath, - [JSON.stringify(header), '{bad json', ...entries.map((entry) => JSON.stringify(entry))].join( - '\n' - ) - ) - const result = await prepareLegacyTranscriptImport({ - agent: 'pi', - sessionId: 'session', - options: { filePath } - }) - if (!result.ok) { - throw new Error(result.error) - } - return result.items -} - -describe('saved Pi context import', () => { - it.each(PI_CONTEXT_FIXTURES)('$name', async (fixture) => { - const items = await prepare(fixture.entries, 'version' in fixture ? fixture.version : 3) - expect( - items.map((item) => - item.body.kind === 'message' - ? item.body.blocks.flatMap((block) => (block.type === 'text' ? [block.text] : [])) - : [] - ) - ).toEqual(fixture.texts.map((text) => [text])) - }) - - it('persists compaction and branch summaries in the adopted journal across reopen', async () => { - const fixture = PI_CONTEXT_FIXTURES[0] - if (!fixture) { - throw new Error('missing compaction fixture') - } - const expected = await prepare([ - ...fixture.entries, - { - type: 'branch_summary', - id: 'branch', - parentId: 'later', - fromId: 'discarded', - summary: 'Saved branch details.' - } - ]) - const filePath = join(root, 'session.jsonl') - const options = { - stateDirectory: join(root, 'journal'), - identity: { - sessionId: 'native-chat', - workspaceId: 'workspace', - hostId: 'host', - agent: 'pi' as const, - providerHandle: { transport: 'jsonl-rpc', agent: 'pi', nativeId: filePath } - } - } - const journal = await journals.open(options) - expect( - await importLegacyTranscriptIntoJournal({ - journal, - agent: 'pi', - sessionId: 'session', - fence: 1, - options: { filePath } - }) - ).toMatchObject({ ok: true, imported: 4, replaced: true }) - await journal.close() - const reopened = await journals.open(options) - expect(reopened.snapshot().items.map((item) => item.body)).toEqual( - expected.map((item) => item.body) - ) - expect(JSON.stringify(reopened.snapshot().items)).toContain('Saved branch details.') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.spec.ts b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.spec.ts new file mode 100644 index 00000000000..d054c0a8681 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-producer-inheritance.spec.ts @@ -0,0 +1,234 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentJournalRenderItem +} from '../../../shared/agent-session-journal-types' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { + buildJournalItemRow, + buildJournalTombstoneRow, + journalLifecycleBatchRowBuilder +} from './journal-row-builders' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalProducerInheritanceTests(): void { + // 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 + // revision naming one replaces the whole bundle, which is how a provisional + // stamp is corrected. + + const child: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'child', + turnId: 't', + ordinal: 1 + } + const own: AgentJournalItemIdentity = { + provider: 'codex', + threadId: 'root', + turnId: 't', + ordinal: 1 + } + const linkage = { + agentId: 'child', + parentAgentId: 'spawner', + providerParentRef: 'call-1', + producerKind: 'agent' as const, + attempt: 2 + } + + function text(value: string): AgentJournalMessageItem { + return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } + } + + function producerOf(item: AgentJournalRenderItem | undefined) { + if (!item) { + return undefined + } + const { agentId, parentAgentId, providerParentRef, producerKind, attempt } = item + return { agentId, parentAgentId, providerParentRef, producerKind, attempt } + } + + const NO_PRODUCER = { + agentId: undefined, + parentAgentId: undefined, + providerParentRef: undefined, + producerKind: undefined, + attempt: undefined + } + + function seeded() { + const state = createJournalReducerState('session-1', 'epoch-1') + let seq = 0 + const write = ( + identity: AgentJournalItemIdentity, + body: AgentJournalMessageItem, + stamp?: Parameters[0]['linkage'] + ) => { + seq += 1 + applyJournalRow( + state, + buildJournalItemRow({ + state, + identity, + body, + seq, + fence: 1, + ts: 1_000 + seq, + ...(stamp ? { linkage: stamp } : {}), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + } + const settle = (identities: AgentJournalItemIdentity[]) => { + seq += 1 + applyJournalRow( + state, + journalLifecycleBatchRowBuilder( + () => state, + `settle-${seq}`, + identities.map((identity) => ({ + kind: 'item' as const, + identity, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), + { fence: 1 } + )(seq, 1_000 + seq) + ) + } + const remove = (identity: AgentJournalItemIdentity) => { + seq += 1 + const itemId = agentJournalItemKey(identity) + applyJournalRow( + state, + buildJournalTombstoneRow({ state, itemId, seq, fence: 1, ts: 1_000 + seq }) + ) + } + const item = (identity: AgentJournalItemIdentity) => + state.items.get(agentJournalItemKey(identity)) + return { state, write, settle, remove, item } + } + + describe('producer inheritance in the reducer', () => { + it('keeps the whole bundle when a plain revision names no producer', () => { + const { write, item } = seeded() + write(child, text('looking'), linkage) + write(child, text('looked')) + + expect(item(child)).toMatchObject({ revision: 2, body: text('looked'), sequence: 1 }) + expect(producerOf(item(child))).toEqual(linkage) + }) + + it("keeps each row's own producer when one batch settles a child's row and the session's", () => { + const { write, settle, item } = seeded() + write(child, text('child working'), linkage) + write(own, text('own working')) + settle([child, own]) + + expect(item(child)).toMatchObject({ revision: 2, body: text('settled') }) + expect(producerOf(item(child))).toEqual(linkage) + expect(item(own)).toMatchObject({ revision: 2, body: text('settled') }) + expect(producerOf(item(own))).toEqual(NO_PRODUCER) + }) + + it('replaces the bundle wholesale when a revision names any producer', () => { + const { write, item } = seeded() + write(child, text('looking'), linkage) + write(child, text('looking'), { agentId: 'task-1', producerKind: 'agent' }) + + expect(producerOf(item(child))).toEqual({ + ...NO_PRODUCER, + agentId: 'task-1', + producerKind: 'agent' + }) + }) + + it('gives a row re-created after its removal nothing from the removed one', () => { + const { write, remove, item } = seeded() + write(child, text('first life'), linkage) + remove(child) + write(child, text('second life')) + + expect(item(child)?.body).toEqual(text('second life')) + expect(producerOf(item(child))).toEqual(NO_PRODUCER) + }) + }) + + describe('producer inheritance across a reopen', () => { + let root: string + const journals = createTrackedJournalOpener() + const open = () => + journals.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('root') + }, + stateDirectory: root, + now: () => 1_000 + }) + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-producer-inheritance-')) + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + it('replays an inherited producer from disk exactly as it was folded live', async () => { + const journal = await open() + await journal.appendItem(child, text('working'), { + fence: 1, + ...linkage, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(own, text('working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(child, text('still working'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendLifecycleBatch({ + settlementId: 'settle-1', + fence: 1, + mutations: [ + { + kind: 'item', + identity: child, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: own, + body: text('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + const live = journal.snapshot().items + expect(live.map((item) => [item.revision, producerOf(item)])).toEqual([ + [3, linkage], + [2, NO_PRODUCER] + ]) + + await journal.close() + const reopened = await open() + expect(reopened.snapshot().items).toEqual(live) + }) + }) +} 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 deleted file mode 100644 index 8dea06cebee..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-producer-inheritance.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem, - AgentJournalRenderItem -} from '../../../shared/agent-session-journal-types' -import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { - buildJournalItemRow, - buildJournalTombstoneRow, - journalLifecycleBatchRowBuilder -} from './journal-row-builders' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -// 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 -// revision naming one replaces the whole bundle, which is how a provisional -// stamp is corrected. - -const child: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'child', - turnId: 't', - ordinal: 1 -} -const own: AgentJournalItemIdentity = { - provider: 'codex', - threadId: 'root', - turnId: 't', - ordinal: 1 -} -const linkage = { - agentId: 'child', - parentAgentId: 'spawner', - providerParentRef: 'call-1', - producerKind: 'agent' as const, - attempt: 2 -} - -function text(value: string): AgentJournalMessageItem { - return { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: value }] } -} - -function producerOf(item: AgentJournalRenderItem | undefined) { - if (!item) { - return undefined - } - const { agentId, parentAgentId, providerParentRef, producerKind, attempt } = item - return { agentId, parentAgentId, providerParentRef, producerKind, attempt } -} - -const NO_PRODUCER = { - agentId: undefined, - parentAgentId: undefined, - providerParentRef: undefined, - producerKind: undefined, - attempt: undefined -} - -function seeded() { - const state = createJournalReducerState('session-1', 'epoch-1') - let seq = 0 - const write = ( - identity: AgentJournalItemIdentity, - body: AgentJournalMessageItem, - stamp?: Parameters[0]['linkage'] - ) => { - seq += 1 - applyJournalRow( - state, - buildJournalItemRow({ - state, - identity, - body, - seq, - fence: 1, - ts: 1_000 + seq, - ...(stamp ? { linkage: stamp } : {}), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ) - } - const settle = (identities: AgentJournalItemIdentity[]) => { - seq += 1 - applyJournalRow( - state, - journalLifecycleBatchRowBuilder( - () => state, - `settle-${seq}`, - identities.map((identity) => ({ - kind: 'item' as const, - identity, - body: text('settled'), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - })), - { fence: 1 } - )(seq, 1_000 + seq) - ) - } - const remove = (identity: AgentJournalItemIdentity) => { - seq += 1 - const itemId = agentJournalItemKey(identity) - applyJournalRow( - state, - buildJournalTombstoneRow({ state, itemId, seq, fence: 1, ts: 1_000 + seq }) - ) - } - const item = (identity: AgentJournalItemIdentity) => - state.items.get(agentJournalItemKey(identity)) - return { state, write, settle, remove, item } -} - -describe('producer inheritance in the reducer', () => { - it('keeps the whole bundle when a plain revision names no producer', () => { - const { write, item } = seeded() - write(child, text('looking'), linkage) - write(child, text('looked')) - - expect(item(child)).toMatchObject({ revision: 2, body: text('looked'), sequence: 1 }) - expect(producerOf(item(child))).toEqual(linkage) - }) - - it("keeps each row's own producer when one batch settles a child's row and the session's", () => { - const { write, settle, item } = seeded() - write(child, text('child working'), linkage) - write(own, text('own working')) - settle([child, own]) - - expect(item(child)).toMatchObject({ revision: 2, body: text('settled') }) - expect(producerOf(item(child))).toEqual(linkage) - expect(item(own)).toMatchObject({ revision: 2, body: text('settled') }) - expect(producerOf(item(own))).toEqual(NO_PRODUCER) - }) - - it('replaces the bundle wholesale when a revision names any producer', () => { - const { write, item } = seeded() - write(child, text('looking'), linkage) - write(child, text('looking'), { agentId: 'task-1', producerKind: 'agent' }) - - expect(producerOf(item(child))).toEqual({ - ...NO_PRODUCER, - agentId: 'task-1', - producerKind: 'agent' - }) - }) - - it('gives a row re-created after its removal nothing from the removed one', () => { - const { write, remove, item } = seeded() - write(child, text('first life'), linkage) - remove(child) - write(child, text('second life')) - - expect(item(child)?.body).toEqual(text('second life')) - expect(producerOf(item(child))).toEqual(NO_PRODUCER) - }) -}) - -describe('producer inheritance across a reopen', () => { - let root: string - const journals = createTrackedJournalOpener() - const open = () => - journals.open({ - identity: { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: codexProviderHandle('root') - }, - stateDirectory: root, - now: () => 1_000 - }) - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-producer-inheritance-')) - }) - - afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) - }) - - it('replays an inherited producer from disk exactly as it was folded live', async () => { - const journal = await open() - await journal.appendItem(child, text('working'), { - fence: 1, - ...linkage, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(own, text('working'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(child, text('still working'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendLifecycleBatch({ - settlementId: 'settle-1', - fence: 1, - mutations: [ - { - kind: 'item', - identity: child, - body: text('settled'), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }, - { - kind: 'item', - identity: own, - body: text('settled'), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ] - }) - const live = journal.snapshot().items - expect(live.map((item) => [item.revision, producerOf(item)])).toEqual([ - [3, linkage], - [2, NO_PRODUCER] - ]) - - await journal.close() - const reopened = await open() - expect(reopened.snapshot().items).toEqual(live) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts new file mode 100644 index 00000000000..c4094a959f7 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-message-contracts.test.ts @@ -0,0 +1,20 @@ +import { afterEach, describe, vi } from 'vitest' +import { registerJournalPiContextImportTests } from './journal-pi-context-import.spec' +import { registerJournalProducerInheritanceTests } from './journal-producer-inheritance.spec' +import { registerJournalUnsentSendHoldTests } from './journal-unsent-send-hold.spec' +import { registerQueuedMessageBookkeepingFailureTests } from './queued-message-bookkeeping-failure.spec' +import { registerQueuedMessageDeliveredEchoTests } from './queued-message-delivered-echo.spec' +import { registerQueuedMessagePauseTests } from './queued-message-pause.spec' +import { registerQueuedMessageStoreTests } from './queued-message-store.spec' +import { registerQueuedMessageTextUpdateTests } from './queued-message-text-update.spec' + +afterEach(() => vi.restoreAllMocks()) + +describe('journal-pi-context-import', registerJournalPiContextImportTests) +describe('journal-producer-inheritance', registerJournalProducerInheritanceTests) +describe('journal-unsent-send-hold', registerJournalUnsentSendHoldTests) +describe('queued-message-bookkeeping-failure', registerQueuedMessageBookkeepingFailureTests) +describe('queued-message-delivered-echo', registerQueuedMessageDeliveredEchoTests) +describe('queued-message-pause', registerQueuedMessagePauseTests) +describe('queued-message-store', registerQueuedMessageStoreTests) +describe('queued-message-text-update', registerQueuedMessageTextUpdateTests) diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.spec.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.spec.ts new file mode 100644 index 00000000000..e2e83bfd0c4 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.spec.ts @@ -0,0 +1,129 @@ +// A failed start's row and the queued messages it failed land in ONE append, the messages first: +// no reader meets one without the other, and the messages sit above the row that says why. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +export function registerJournalQueuedRejectionBatchTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-start', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + + const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { + surface: 'rejection' + }) + const ERROR_ROW = { provider: 'orca', clientMessageId: 'start-failure:gen-1' } as const + + let root: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-rejection-batch-')) + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + async function openWithQueued(...ids: string[]): Promise { + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => 'epoch-1' + }) + for (const id of ids) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, + fence: 0, + handoverRecorded: true + }) + } + return journal + } + + function startFailureBatch(mutations = 1) { + return { + settlementId: 'start-failure:gen-1', + fence: 0, + recovered: true as const, + mutations: Array.from({ length: mutations }, () => ({ + kind: 'item' as const, + identity: ERROR_ROW, + body: { kind: 'status' as const, tone: 'error' as const, text: 'Claude did not start.' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + })), + rejectsQueued: START_FAILED + } + } + + it('writes the rejections first and the row after them, and draws the messages above it', async () => { + const journal = await openWithQueued('first', 'second') + const before = journal.cursor().sequence + + await journal.appendLifecycleBatch(startFailureBatch()) + + expect(journal.cursor().sequence).toBe(before + 3) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'rejected', + 'rejected' + ]) + const order = journal.snapshot().items.map((item) => item.itemId) + expect(order).toEqual([ + agentJournalSubmissionKey('first'), + agentJournalSubmissionKey('second'), + 'orca:start-failure%3Agen-1' + ]) + }) + + it('writes neither when the row cannot be written', async () => { + const journal = await openWithQueued('first') + const before = journal.cursor().sequence + + // An empty batch breaks the row's bound, so the transaction rolls back as a whole. + await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow( + 'journal_lifecycle_batch_mutation_bound_exceeded' + ) + + expect(journal.cursor().sequence).toBe(before) + expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) + }) + + // A Stop that reaches the lane first takes the message back; the failed start then failed no one. + it('writes nothing when a Stop withdrew every queued message first', async () => { + const journal = await openWithQueued('first') + const withdrawal = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + + await Promise.all([ + journal.rejectQueuedSubmissions(0, withdrawal), + journal.appendLifecycleBatch(startFailureBatch()) + ]) + + expect(journal.submissions()[0]?.rejection).toEqual({ kind: 'cancelled' }) + expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ + agentJournalSubmissionKey('first') + ]) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts b/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts deleted file mode 100644 index cd14ce2fe94..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-queued-rejection-batch.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -// A failed start's row and the queued messages it failed land in ONE append, the messages first: -// no reader meets one without the other, and the messages sit above the row that says why. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, expect, it } from 'vitest' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { agentJournalSubmissionKey } from '../../../shared/agent-session-journal-item-key' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from './journal-store' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener -} from './journal-host-database-test-support' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-start', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) -} - -const START_FAILED = agentSessionFailureWords(agentSessionFailureFact('providerStartFailed'), { - surface: 'rejection' -}) -const ERROR_ROW = { provider: 'orca', clientMessageId: 'start-failure:gen-1' } as const - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-rejection-batch-')) -}) - -afterEach(async () => { - await closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -async function openWithQueued(...ids: string[]): Promise { - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => (clock += 1), - mintEpoch: () => 'epoch-1' - }) - for (const id of ids) { - await journal.appendSubmission({ - clientMessageId: id, - payloadFingerprint: `fp-${id}`, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: id }] }, - fence: 0, - handoverRecorded: true - }) - } - return journal -} - -function startFailureBatch(mutations = 1) { - return { - settlementId: 'start-failure:gen-1', - fence: 0, - recovered: true as const, - mutations: Array.from({ length: mutations }, () => ({ - kind: 'item' as const, - identity: ERROR_ROW, - body: { kind: 'status' as const, tone: 'error' as const, text: 'Claude did not start.' }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - })), - rejectsQueued: START_FAILED - } -} - -it('writes the rejections first and the row after them, and draws the messages above it', async () => { - const journal = await openWithQueued('first', 'second') - const before = journal.cursor().sequence - - await journal.appendLifecycleBatch(startFailureBatch()) - - expect(journal.cursor().sequence).toBe(before + 3) - expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'rejected', - 'rejected' - ]) - const order = journal.snapshot().items.map((item) => item.itemId) - expect(order).toEqual([ - agentJournalSubmissionKey('first'), - agentJournalSubmissionKey('second'), - 'orca:start-failure%3Agen-1' - ]) -}) - -it('writes neither when the row cannot be written', async () => { - const journal = await openWithQueued('first') - const before = journal.cursor().sequence - - // An empty batch breaks the row's bound, so the transaction rolls back as a whole. - await expect(journal.appendLifecycleBatch(startFailureBatch(0))).rejects.toThrow( - 'journal_lifecycle_batch_mutation_bound_exceeded' - ) - - expect(journal.cursor().sequence).toBe(before) - expect(journal.submissions().map((entry) => entry.dispatchState)).toEqual(['pending']) -}) - -// A Stop that reaches the lane first takes the message back; the failed start then failed no one. -it('writes nothing when a Stop withdrew every queued message first', async () => { - const journal = await openWithQueued('first') - const withdrawal = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' - }) - - await Promise.all([ - journal.rejectQueuedSubmissions(0, withdrawal), - journal.appendLifecycleBatch(startFailureBatch()) - ]) - - expect(journal.submissions()[0]?.rejection).toEqual({ kind: 'cancelled' }) - expect(journal.snapshot().items.map((item) => item.itemId)).toEqual([ - agentJournalSubmissionKey('first') - ]) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-recovery-contracts.test.ts b/src/main/native-chat/agent-session-journal/journal-recovery-contracts.test.ts new file mode 100644 index 00000000000..245c598ec76 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-recovery-contracts.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, vi } from 'vitest' +import { registerJournalStoreTests } from './journal-store.spec' +import { registerJournalCrashBoundaryTests } from './journal-crash-boundary.spec' +import { registerJournalEpochReplacementTests } from './journal-epoch-replacement.spec' +import { registerJournalItemProvenanceTests } from './journal-item-provenance.spec' +import { registerJournalLifecycleBatchAppenderTests } from './journal-lifecycle-batch-appender.spec' +import { registerJournalRestartReconciliationTests } from './journal-restart-reconciliation.spec' +import { registerJournalUnknownContentTests } from './journal-unknown-content.spec' +import { registerJournalUnknownRowKindTests } from './journal-unknown-row-kind.spec' +import { registerJournalStopAndResumeRowsTests } from './journal-stop-and-resume-rows.spec' + +afterEach(() => vi.restoreAllMocks()) + +describe('journal-store', registerJournalStoreTests) +describe('journal-crash-boundary', registerJournalCrashBoundaryTests) +describe('journal-epoch-replacement', registerJournalEpochReplacementTests) +describe('journal-item-provenance', registerJournalItemProvenanceTests) +describe('journal-lifecycle-batch-appender', registerJournalLifecycleBatchAppenderTests) +describe('journal-restart-reconciliation', registerJournalRestartReconciliationTests) +describe('journal-unknown-content', registerJournalUnknownContentTests) +describe('journal-unknown-row-kind', registerJournalUnknownRowKindTests) +describe('journal-stop-and-resume-rows', registerJournalStopAndResumeRowsTests) diff --git a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.spec.ts b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.spec.ts new file mode 100644 index 00000000000..54f2f9dc250 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.spec.ts @@ -0,0 +1,335 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// Wiring the restart reconciler: what provider history is allowed to decide +// about a submission the crash boundary could only doubt. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import type { + AgentJournalItemIdentity, + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { digestPayload } from './journal-payload-bounds' +import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' +import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' +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' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalRestartReconciliationTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('provider-1', null) + } + + function claudeIdentity(uuid: string): AgentJournalItemIdentity { + return { provider: 'claude', sessionId: 'provider-1', uuid } + } + + let root: string + let clock = 1_000 + + function tick(): number { + clock += 1 + return clock + } + + function userMessage(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } + + const journals = createTrackedJournalOpener() + + async function open() { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) + } + + function history(uuid: string, text: string): ProviderHistoryItem { + return { + providerItemId: uuid, + clientMessageId: null, + payloadFingerprint: digestPayload(text), + identity: claudeIdentity(uuid) + } + } + + function window( + items: ProviderHistoryItem[], + overrides: Partial = {} + ): ProviderHistoryWindow { + return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } + } + + /** A host that wrote the submission row and died before learning its outcome. */ + async function reopenAfterCrash( + body: AgentJournalMessageItem = userMessage('deploy the thing'), + text = 'deploy the thing' + ) { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload(text), + body, + fence: 1 + }) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + return restarted + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-restart-reconcile-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('reconcileJournalSubmissionsAgainstHistory', () => { + it('settles a message found in provider history as accepted on its provider identity', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([history('uuid-1', 'deploy the thing')]) + }) + + expect(settled).toEqual(['cm_1']) + const submission = journal.submissions()[0] + expect(submission?.dispatchState).toBe('accepted') + expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1'))) + }) + + it('settles a message provably absent from history as rejected: not_delivered', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([]) + }) + + expect(settled).toEqual(['cm_1']) + const submission = journal.submissions()[0] + expect(submission?.dispatchState).toBe('rejected') + // A sentence, since released clients print the reason as it is, and the fact beside it. + expect(submission?.reason).toBe( + agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) + .reason + ) + expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) + // Nobody failed: the crash stranded it before the provider took it. + expect(submission && classifyDispatchRejection(submission)).toEqual({ + category: 'undelivered', + verdict: null, + kind: 'notDelivered' + }) + }) + + it('leaves a submission unknown while the provider reports a turn in flight', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([], { turnInFlight: true }) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('leaves a submission unknown when the history boundary is inconsistent', async () => { + const journal = await reopenAfterCrash() + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([], { boundaryConsistent: false }) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('refuses to reject a submission carrying an attachment it cannot fingerprint', async () => { + const journal = await reopenAfterCrash( + { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'look at this' }, + { type: 'image-ref', path: '/tmp/shot.png' } + ] + }, + 'look at this' + ) + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([]) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('leaves a send that carried stopped history unknown: its provider copy leads with that history', async () => { + const journal = await open() + for (const clientMessageId of ['stopped', 'cm_1']) { + await journal.appendSubmission({ + clientMessageId, + payloadFingerprint: digestPayload(clientMessageId), + body: userMessage(clientMessageId), + fence: 1, + handoverRecorded: true + }) + } + await journal.resolveDispatch({ + clientMessageId: 'cm_1', + state: 'pending', + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE, + carries: ['stopped'] + }) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal: restarted, + fence: 2, + history: window([]) + }) + + expect(settled).toEqual([]) + expect(restarted.submission('cm_1')?.dispatchState).toBe('unknown') + expect(restarted.stoppedHistoryCarried().get('cm_1')).toEqual(['stopped']) + }) + + it('leaves multi-block text sends unknown because Claude joins them before recording history', async () => { + const journal = await reopenAfterCrash( + { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'first' }, + { type: 'text', text: 'second' } + ] + }, + 'first\nsecond' + ) + + const settled = await reconcileJournalSubmissionsAgainstHistory({ + journal, + fence: 2, + history: window([history('uuid-1', 'first\nsecond')]) + }) + + expect(settled).toEqual([]) + expect(journal.submissions()[0]?.dispatchState).toBe('unknown') + }) + + it('does not let an item the journal already committed stand in for a new send', async () => { + const journal = await open() + // An identical message, delivered and committed BEFORE the one that crashed. + await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendSubmission({ + clientMessageId: 'cm_1', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + await reconcileJournalSubmissionsAgainstHistory({ + journal: restarted, + fence: 2, + history: window([history('uuid-old', 'deploy the thing')]) + }) + + expect(restarted.submissions()[0]?.dispatchState).toBe('rejected') + }) + + it('does not let an older accepted provider item stand in for a new identical send', async () => { + const journal = await open() + await journal.appendSubmission({ + clientMessageId: 'cm_old', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'cm_old', + state: 'accepted', + providerIdentity: claudeIdentity('uuid-old'), + fence: 1 + }) + await journal.appendSubmission({ + clientMessageId: 'cm_new', + payloadFingerprint: digestPayload('deploy the thing'), + body: userMessage('deploy the thing'), + fence: 1 + }) + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + await reconcileJournalSubmissionsAgainstHistory({ + journal: restarted, + fence: 2, + history: window([history('uuid-old', 'deploy the thing')]) + }) + + expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'accepted', + 'rejected' + ]) + expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) + }) + + it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { + const journal = await open() + for (const id of ['cm_1', 'cm_2']) { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: digestPayload('ping'), + body: userMessage('ping'), + fence: 1 + }) + } + const restarted = await open() + await restarted.markPendingSubmissionsUnknown(2) + + await reconcileJournalSubmissionsAgainstHistory({ + journal: restarted, + fence: 2, + history: window([history('uuid-1', 'ping'), history('uuid-2', 'ping')]) + }) + + expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ + 'unknown', + 'unknown' + ]) + }) + }) +} 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 deleted file mode 100644 index 83c583a5820..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-restart-reconciliation.test.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Wiring the restart reconciler: what provider history is allowed to decide -// about a submission the crash boundary could only doubt. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import type { - AgentJournalItemIdentity, - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { digestPayload } from './journal-payload-bounds' -import { reconcileJournalSubmissionsAgainstHistory } from './journal-restart-reconciliation' -import type { ProviderHistoryItem, ProviderHistoryWindow } from './journal-submission-reconciler' -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' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('provider-1', null) -} - -function claudeIdentity(uuid: string): AgentJournalItemIdentity { - return { provider: 'claude', sessionId: 'provider-1', uuid } -} - -let root: string -let clock = 1_000 - -function tick(): number { - clock += 1 - return clock -} - -function userMessage(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -const journals = createTrackedJournalOpener() - -async function open() { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}` - }) -} - -function history(uuid: string, text: string): ProviderHistoryItem { - return { - providerItemId: uuid, - clientMessageId: null, - payloadFingerprint: digestPayload(text), - identity: claudeIdentity(uuid) - } -} - -function window( - items: ProviderHistoryItem[], - overrides: Partial = {} -): ProviderHistoryWindow { - return { items, boundaryConsistent: true, turnInFlight: false, ...overrides } -} - -/** A host that wrote the submission row and died before learning its outcome. */ -async function reopenAfterCrash( - body: AgentJournalMessageItem = userMessage('deploy the thing'), - text = 'deploy the thing' -) { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload(text), - body, - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - return restarted -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-restart-reconcile-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('reconcileJournalSubmissionsAgainstHistory', () => { - it('settles a message found in provider history as accepted on its provider identity', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([history('uuid-1', 'deploy the thing')]) - }) - - expect(settled).toEqual(['cm_1']) - const submission = journal.submissions()[0] - expect(submission?.dispatchState).toBe('accepted') - expect(submission?.providerItemId).toBe(agentJournalItemKey(claudeIdentity('uuid-1'))) - }) - - it('settles a message provably absent from history as rejected: not_delivered', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual(['cm_1']) - const submission = journal.submissions()[0] - expect(submission?.dispatchState).toBe('rejected') - // A sentence, since released clients print the reason as it is, and the fact beside it. - expect(submission?.reason).toBe( - agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { surface: 'rejection' }) - .reason - ) - expect(submission?.rejection).toEqual({ kind: 'notDelivered' }) - // Nobody failed: the crash stranded it before the provider took it. - expect(submission && classifyDispatchRejection(submission)).toEqual({ - category: 'undelivered', - verdict: null, - kind: 'notDelivered' - }) - }) - - it('leaves a submission unknown while the provider reports a turn in flight', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([], { turnInFlight: true }) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves a submission unknown when the history boundary is inconsistent', async () => { - const journal = await reopenAfterCrash() - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([], { boundaryConsistent: false }) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('refuses to reject a submission carrying an attachment it cannot fingerprint', async () => { - const journal = await reopenAfterCrash( - { - kind: 'message', - role: 'user', - blocks: [ - { type: 'text', text: 'look at this' }, - { type: 'image-ref', path: '/tmp/shot.png' } - ] - }, - 'look at this' - ) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('leaves a send that carried stopped history unknown: its provider copy leads with that history', async () => { - const journal = await open() - for (const clientMessageId of ['stopped', 'cm_1']) { - await journal.appendSubmission({ - clientMessageId, - payloadFingerprint: digestPayload(clientMessageId), - body: userMessage(clientMessageId), - fence: 1, - handoverRecorded: true - }) - } - await journal.resolveDispatch({ - clientMessageId: 'cm_1', - state: 'pending', - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE, - carries: ['stopped'] - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([]) - }) - - expect(settled).toEqual([]) - expect(restarted.submission('cm_1')?.dispatchState).toBe('unknown') - expect(restarted.stoppedHistoryCarried().get('cm_1')).toEqual(['stopped']) - }) - - it('leaves multi-block text sends unknown because Claude joins them before recording history', async () => { - const journal = await reopenAfterCrash( - { - kind: 'message', - role: 'user', - blocks: [ - { type: 'text', text: 'first' }, - { type: 'text', text: 'second' } - ] - }, - 'first\nsecond' - ) - - const settled = await reconcileJournalSubmissionsAgainstHistory({ - journal, - fence: 2, - history: window([history('uuid-1', 'first\nsecond')]) - }) - - expect(settled).toEqual([]) - expect(journal.submissions()[0]?.dispatchState).toBe('unknown') - }) - - it('does not let an item the journal already committed stand in for a new send', async () => { - const journal = await open() - // An identical message, delivered and committed BEFORE the one that crashed. - await journal.appendItem(claudeIdentity('uuid-old'), userMessage('deploy the thing'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendSubmission({ - clientMessageId: 'cm_1', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-old', 'deploy the thing')]) - }) - - expect(restarted.submissions()[0]?.dispatchState).toBe('rejected') - }) - - it('does not let an older accepted provider item stand in for a new identical send', async () => { - const journal = await open() - await journal.appendSubmission({ - clientMessageId: 'cm_old', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'cm_old', - state: 'accepted', - providerIdentity: claudeIdentity('uuid-old'), - fence: 1 - }) - await journal.appendSubmission({ - clientMessageId: 'cm_new', - payloadFingerprint: digestPayload('deploy the thing'), - body: userMessage('deploy the thing'), - fence: 1 - }) - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-old', 'deploy the thing')]) - }) - - expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'accepted', - 'rejected' - ]) - expect(restarted.submissions()[1]?.rejection).toEqual({ kind: 'notDelivered' }) - }) - - it('leaves two identical unsettled sends unknown rather than guessing between them', async () => { - const journal = await open() - for (const id of ['cm_1', 'cm_2']) { - await journal.appendSubmission({ - clientMessageId: id, - payloadFingerprint: digestPayload('ping'), - body: userMessage('ping'), - fence: 1 - }) - } - const restarted = await open() - await restarted.markPendingSubmissionsUnknown(2) - - await reconcileJournalSubmissionsAgainstHistory({ - journal: restarted, - fence: 2, - history: window([history('uuid-1', 'ping'), history('uuid-2', 'ping')]) - }) - - expect(restarted.submissions().map((entry) => entry.dispatchState)).toEqual([ - 'unknown', - 'unknown' - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-context-usage.spec.ts b/src/main/native-chat/agent-session-journal/journal-row-context-usage.spec.ts new file mode 100644 index 00000000000..a6d4df699ff --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-context-usage.spec.ts @@ -0,0 +1,167 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// Context facts ride turn rows. Every kind the writer produces must replay, and +// one this build cannot read must cost the fact, never the row or the journal. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { parseJournalRow } from './journal-row-schema' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + updateTestJournalRowJson +} from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalRowContextUsageTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('claude-session', null) + } + + const USAGE = { + inputTokens: 1, + cacheCreationInputTokens: 2, + cacheReadInputTokens: 90_000, + outputTokens: 5 + } + + /** One of each part and kind, as the writer builds them. */ + const FACTS: AgentSessionContextUsage[] = [ + { window: { tokens: 1_000_000, capturedAt: 3 } }, + { used: { kind: 'estimate', usage: USAGE, capturedAt: 4 } }, + { used: { kind: 'unknown', capturedAt: 5 } }, + { + used: { + kind: 'report', + model: 'claude-fable-5-1[1m]', + usedTokens: 1_100_000, + windowTokens: 1_000_000, + percentage: 110, + autoCompactAtTokens: 967_000, + categories: [ + { name: 'Messages', tokens: 1_100_000 }, + { name: 'MCP tools (deferred)', tokens: 0, deferred: true } + ], + capturedAt: 6 + }, + window: { tokens: 1_000_000, capturedAt: 6 } + } + ] + + let root: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + const open = () => + journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => 'epoch-1' + }) + + function row(ordinal: number): AgentJournalItemIdentity { + return { provider: 'orca', clientMessageId: `row-${ordinal}` } + } + + const turn = (turnId: string, contextUsage?: AgentSessionContextUsage): AgentJournalItemBody => ({ + kind: 'turn', + turnId, + state: 'completed', + ...(contextUsage ? { contextUsage } : {}) + }) + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-context-usage-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('context facts on replayed turn rows', () => { + it('replays every part and kind the writer produces, unchanged', async () => { + const journal = await open() + for (const [index, facts] of FACTS.entries()) { + await journal.appendItem(row(index), turn(`turn-${index}`, facts), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + const written = journal.snapshot().items.map((item) => item.body) + await journal.close() + + const reopened = await open() + expect(reopened.snapshot().items.map((item) => item.body)).toEqual(written) + expect(written).toHaveLength(FACTS.length) + }) + + it('keeps a turn row whose facts it cannot read, and everything after it, minus the facts', async () => { + const journal = await open() + await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem( + row(1), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.close() + const opened = openTestJournalHostDatabase(root) + try { + const rowJson = liveTestJournalRows(opened.db, IDENTITY.sessionId)[1]?.rowJson ?? '' + const future = JSON.parse(rowJson) + future.body.contextUsage = { used: { kind: 'measured-later', tokens: 'many' } } + updateTestJournalRowJson(opened.db, IDENTITY.sessionId, 2, JSON.stringify(future)) + } finally { + opened.close() + } + + const reopened = await open() + expect(reopened.snapshot().items.map((item) => item.body)).toEqual([ + turn('turn-0'), + { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] } + ]) + }) + + it('drops unreadable facts from a settlement batch without rejecting the batch', () => { + const parsed = parseJournalRow( + JSON.stringify({ + v: 1, + epoch: 'epoch-1', + seq: 4, + fence: 1, + ts: 1, + kind: 'lifecycle-batch', + settlementId: 'stale-session:1', + mutations: [ + { + kind: 'item', + itemId: 'orca:row-0', + revision: 2, + body: { ...turn('turn-0'), contextUsage: { window: { tokens: -1 } } } + } + ] + }) + ) + expect(parsed.ok && parsed.row.kind === 'lifecycle-batch' && parsed.row.mutations).toEqual([ + { kind: 'item', itemId: 'orca:row-0', revision: 2, body: turn('turn-0') } + ]) + }) + }) +} 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 deleted file mode 100644 index 28dd96f9f54..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-row-context-usage.test.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Context facts ride turn rows. Every kind the writer produces must replay, and -// one this build cannot read must cost the fact, never the row or the journal. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { AgentSessionContextUsage } from '../../../shared/agent-session-context-usage' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { parseJournalRow } from './journal-row-schema' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - liveTestJournalRows, - updateTestJournalRowJson -} from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('claude-session', null) -} - -const USAGE = { - inputTokens: 1, - cacheCreationInputTokens: 2, - cacheReadInputTokens: 90_000, - outputTokens: 5 -} - -/** One of each part and kind, as the writer builds them. */ -const FACTS: AgentSessionContextUsage[] = [ - { window: { tokens: 1_000_000, capturedAt: 3 } }, - { used: { kind: 'estimate', usage: USAGE, capturedAt: 4 } }, - { used: { kind: 'unknown', capturedAt: 5 } }, - { - used: { - kind: 'report', - model: 'claude-fable-5-1[1m]', - usedTokens: 1_100_000, - windowTokens: 1_000_000, - percentage: 110, - autoCompactAtTokens: 967_000, - categories: [ - { name: 'Messages', tokens: 1_100_000 }, - { name: 'MCP tools (deferred)', tokens: 0, deferred: true } - ], - capturedAt: 6 - }, - window: { tokens: 1_000_000, capturedAt: 6 } - } -] - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -const open = () => - journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => ++clock, - mintEpoch: () => 'epoch-1' - }) - -function row(ordinal: number): AgentJournalItemIdentity { - return { provider: 'orca', clientMessageId: `row-${ordinal}` } -} - -const turn = (turnId: string, contextUsage?: AgentSessionContextUsage): AgentJournalItemBody => ({ - kind: 'turn', - turnId, - state: 'completed', - ...(contextUsage ? { contextUsage } : {}) -}) - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-context-usage-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('context facts on replayed turn rows', () => { - it('replays every part and kind the writer produces, unchanged', async () => { - const journal = await open() - for (const [index, facts] of FACTS.entries()) { - await journal.appendItem(row(index), turn(`turn-${index}`, facts), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - const written = journal.snapshot().items.map((item) => item.body) - await journal.close() - - const reopened = await open() - expect(reopened.snapshot().items.map((item) => item.body)).toEqual(written) - expect(written).toHaveLength(FACTS.length) - }) - - it('keeps a turn row whose facts it cannot read, and everything after it, minus the facts', async () => { - const journal = await open() - await journal.appendItem(row(0), turn('turn-0', FACTS[0]), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem( - row(1), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const opened = openTestJournalHostDatabase(root) - try { - const rowJson = liveTestJournalRows(opened.db, IDENTITY.sessionId)[1]?.rowJson ?? '' - const future = JSON.parse(rowJson) - future.body.contextUsage = { used: { kind: 'measured-later', tokens: 'many' } } - updateTestJournalRowJson(opened.db, IDENTITY.sessionId, 2, JSON.stringify(future)) - } finally { - opened.close() - } - - const reopened = await open() - expect(reopened.snapshot().items.map((item) => item.body)).toEqual([ - turn('turn-0'), - { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'after' }] } - ]) - }) - - it('drops unreadable facts from a settlement batch without rejecting the batch', () => { - const parsed = parseJournalRow( - JSON.stringify({ - v: 1, - epoch: 'epoch-1', - seq: 4, - fence: 1, - ts: 1, - kind: 'lifecycle-batch', - settlementId: 'stale-session:1', - mutations: [ - { - kind: 'item', - itemId: 'orca:row-0', - revision: 2, - body: { ...turn('turn-0'), contextUsage: { window: { tokens: -1 } } } - } - ] - }) - ) - expect(parsed.ok && parsed.row.kind === 'lifecycle-batch' && parsed.row.mutations).toEqual([ - { kind: 'item', itemId: 'orca:row-0', revision: 2, body: turn('turn-0') } - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-schema-version.spec.ts b/src/main/native-chat/agent-session-journal/journal-row-schema-version.spec.ts new file mode 100644 index 00000000000..01549f231dc --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-schema-version.spec.ts @@ -0,0 +1,75 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows +} from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalRowSchemaVersionTests(): void { + // 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. + // Read raw: the reader upcasts every row to the current version, so only the + // stored row_json says what an older build would see. + describe('journal row schema versions', () => { + let root = '' + const opener = createTrackedJournalOpener() + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-row-version-')) + }) + afterEach(async () => { + await opener.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + it('stamps v3 only on rows that carry a turn item', async () => { + const journal = await opener.open({ + identity: { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'local', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + }, + now: () => 1_000, + stateDirectory: join(root, 'session-1') + }) + const identity = { provider: 'orca' as const, clientMessageId: 'm1' } + await journal.appendItem( + identity, + { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem( + { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:t1' + }, + agentJournalTurnBody({ turnId: 't1', state: 'running', startedAt: 1_000 }), + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.close() + const opened = openTestJournalHostDatabase(join(root, 'session-1')) + try { + 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], + ['item', 2], + ['item', 3] + ]) + } 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 deleted file mode 100644 index 4fdaaa880b1..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-row-schema-version.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalTurnBody } from '../../../shared/agent-session-turn-record' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - liveTestJournalRows -} from './journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -// 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. -// Read raw: the reader upcasts every row to the current version, so only the -// stored row_json says what an older build would see. -describe('journal row schema versions', () => { - let root = '' - const opener = createTrackedJournalOpener() - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-row-version-')) - }) - afterEach(async () => { - await opener.closeAll() - await rm(root, { recursive: true, force: true }) - }) - - it('stamps v3 only on rows that carry a turn item', async () => { - const journal = await opener.open({ - identity: { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'local', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') - }, - now: () => 1_000, - stateDirectory: join(root, 'session-1') - }) - const identity = { provider: 'orca' as const, clientMessageId: 'm1' } - await journal.appendItem( - identity, - { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.appendItem( - { provider: 'legacy', agent: 'codex', sessionId: 'session-1', recordId: 'turn-lifecycle:t1' }, - agentJournalTurnBody({ turnId: 't1', state: 'running', startedAt: 1_000 }), - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.close() - const opened = openTestJournalHostDatabase(join(root, 'session-1')) - try { - 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], - ['item', 2], - ['item', 3] - ]) - } finally { - opened.close() - } - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.spec.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.spec.ts new file mode 100644 index 00000000000..fa3a70cad6e --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.spec.ts @@ -0,0 +1,129 @@ +// The write path's transaction. +// +// A transaction either commits or does not, so the old "a post-append failure +// makes durability ambiguous" latch has nothing left to latch on: the case that +// used to assert the latch asserts the rollback instead. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' +import type { JournalHostDatabase } from './journal-host-database' +import type { JournalRow } from './journal-row-schema' +import { JournalRowWriter } from './journal-row-writer' +import { JournalWriteQueue } from './journal-write-queue' +import { readJournalRowsAfter } from './journal-row-table' +import { + openTestJournalHostDatabase, + readTestJournalRows, + insertTestJournalRow, + publishTestJournalEpoch +} from './journal-host-database-test-support' + +export function registerJournalRowWriterTests(): void { + const SESSION_ID = 'session-1' + const EPOCH = 'epoch-1' + + function row(seq: number, ts: number): JournalRow { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch: EPOCH, + seq, + fence: 0, + ts, + kind: 'item', + itemId: 'item-1', + revision: 1, + body: { kind: 'status', text: 'plain append' } + } + } + + describe('journal row writer', () => { + let root: string + let database: JournalHostDatabase + let readOnly = false + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-')) + database = openTestJournalHostDatabase(root) + publishTestJournalEpoch(database.db, SESSION_ID, EPOCH) + readOnly = false + }) + + afterEach(async () => { + try { + database.close() + } catch { + // Already closed by the case. + } + await rm(root, { recursive: true, force: true }) + }) + + function writerHarness() { + const committedRows: JournalRow[] = [] + let sequence = 1 + const queue = new JournalWriteQueue(SESSION_ID) + const writer = new JournalRowWriter({ + sessionId: SESSION_ID, + now: () => 1, + serialize: (run) => queue.serialize(run), + database: () => database, + readOnly: () => readOnly, + highestFence: () => 0, + nextSequence: () => sequence, + commit: (committed) => { + committedRows.push(...committed) + sequence = committed.at(-1)!.seq + 1 + } + }) + return { writer, committedRows } + } + + 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. + insertTestJournalRow(database.db, SESSION_ID, row(1, 1)) + + await expect(writer.enqueue(row)).rejects.toThrow() + + expect(readOnly).toBe(false) + expect(committedRows).toHaveLength(0) + 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' + }) + }) + + it('writes several rows as one: a failure on the last leaves none', async () => { + const { writer, committedRows } = writerHarness() + // Sequence 2 is taken, so the second of the two rows violates the primary key. + insertTestJournalRow(database.db, SESSION_ID, row(2, 1)) + + await expect(writer.enqueueRows(() => [row, row])).rejects.toThrow() + + expect(committedRows).toHaveLength(0) + expect( + readTestJournalRows(database.db, SESSION_ID, EPOCH).map((stored) => stored.seq) + ).toEqual([2]) + }) + + it.each(['single', 'batch'] as const)( + 'keeps the save time separate from a pinned JSON timestamp in a %s write', + async (kind) => { + const { writer, committedRows } = writerHarness() + const pinned = (seq: number) => row(seq, 0) + await (kind === 'single' + ? writer.enqueue(pinned) + : writer.enqueueRows(() => [pinned, pinned])) + + const saved = readJournalRowsAfter(database.db, SESSION_ID, EPOCH, 0) + expect(saved).toHaveLength(kind === 'single' ? 1 : 2) + expect(saved.map((entry) => entry.ts)).toEqual(saved.map(() => 1)) + expect(saved.map((entry) => JSON.parse(entry.rowJson))).toEqual(committedRows) + expect(committedRows.map((entry) => entry.ts)).toEqual(saved.map(() => 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 deleted file mode 100644 index 24dc191bee9..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -// The write path's transaction. -// -// A transaction either commits or does not, so the old "a post-append failure -// makes durability ambiguous" latch has nothing left to latch on: the case that -// used to assert the latch asserts the rollback instead. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AGENT_SESSION_JOURNAL_SCHEMA_VERSION } from '../../../shared/agent-session-journal-types' -import type { JournalHostDatabase } from './journal-host-database' -import type { JournalRow } from './journal-row-schema' -import { JournalRowWriter } from './journal-row-writer' -import { JournalWriteQueue } from './journal-write-queue' -import { readJournalRowsAfter } from './journal-row-table' -import { - openTestJournalHostDatabase, - readTestJournalRows, - insertTestJournalRow, - publishTestJournalEpoch -} from './journal-host-database-test-support' - -const SESSION_ID = 'session-1' -const EPOCH = 'epoch-1' - -function row(seq: number, ts: number): JournalRow { - return { - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - epoch: EPOCH, - seq, - fence: 0, - ts, - kind: 'item', - itemId: 'item-1', - revision: 1, - body: { kind: 'status', text: 'plain append' } - } -} - -describe('journal row writer', () => { - let root: string - let database: JournalHostDatabase - let readOnly = false - - beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-row-writer-')) - database = openTestJournalHostDatabase(root) - publishTestJournalEpoch(database.db, SESSION_ID, EPOCH) - readOnly = false - }) - - afterEach(async () => { - try { - database.close() - } catch { - // Already closed by the case. - } - await rm(root, { recursive: true, force: true }) - }) - - function writerHarness() { - const committedRows: JournalRow[] = [] - let sequence = 1 - const queue = new JournalWriteQueue(SESSION_ID) - const writer = new JournalRowWriter({ - sessionId: SESSION_ID, - now: () => 1, - serialize: (run) => queue.serialize(run), - database: () => database, - readOnly: () => readOnly, - highestFence: () => 0, - nextSequence: () => sequence, - commit: (committed) => { - committedRows.push(...committed) - sequence = committed.at(-1)!.seq + 1 - } - }) - return { writer, committedRows } - } - - 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. - insertTestJournalRow(database.db, SESSION_ID, row(1, 1)) - - await expect(writer.enqueue(row)).rejects.toThrow() - - expect(readOnly).toBe(false) - expect(committedRows).toHaveLength(0) - 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' - }) - }) - - it('writes several rows as one: a failure on the last leaves none', async () => { - const { writer, committedRows } = writerHarness() - // Sequence 2 is taken, so the second of the two rows violates the primary key. - insertTestJournalRow(database.db, SESSION_ID, row(2, 1)) - - await expect(writer.enqueueRows(() => [row, row])).rejects.toThrow() - - expect(committedRows).toHaveLength(0) - expect(readTestJournalRows(database.db, SESSION_ID, EPOCH).map((stored) => stored.seq)).toEqual( - [2] - ) - }) - - it.each(['single', 'batch'] as const)( - 'keeps the save time separate from a pinned JSON timestamp in a %s write', - async (kind) => { - const { writer, committedRows } = writerHarness() - const pinned = (seq: number) => row(seq, 0) - await (kind === 'single' - ? writer.enqueue(pinned) - : writer.enqueueRows(() => [pinned, pinned])) - - const saved = readJournalRowsAfter(database.db, SESSION_ID, EPOCH, 0) - expect(saved).toHaveLength(kind === 'single' ? 1 : 2) - expect(saved.map((entry) => entry.ts)).toEqual(saved.map(() => 1)) - expect(saved.map((entry) => JSON.parse(entry.rowJson))).toEqual(committedRows) - expect(committedRows.map((entry) => entry.ts)).toEqual(saved.map(() => 0)) - } - ) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-settlement-contracts.test.ts b/src/main/native-chat/agent-session-journal/journal-settlement-contracts.test.ts new file mode 100644 index 00000000000..6365c608de0 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-settlement-contracts.test.ts @@ -0,0 +1,22 @@ +import { afterEach, describe, vi } from 'vitest' +import { registerJournalAtomicTerminalSettlementTests } from './journal-atomic-terminal-settlement.spec' +import { registerJournalDispatchReasonBoundTests } from './journal-dispatch-reason-bound.spec' +import { registerJournalQueuedRejectionBatchTests } from './journal-queued-rejection-batch.spec' +import { registerJournalRowContextUsageTests } from './journal-row-context-usage.spec' +import { registerJournalSubagentItemTests } from './journal-subagent-item.spec' +import { registerJournalSubagentLivenessTests } from './journal-subagent-liveness.spec' +import { registerJournalSubmissionPositionsTests } from './journal-submission-positions.spec' +import { registerJournalSubmissionQueuedLinkTests } from './journal-submission-queued-link.spec' +import { registerJournalTerminalSettlementValidationTests } from './journal-terminal-settlement-validation.spec' + +afterEach(() => vi.restoreAllMocks()) + +describe('journal-atomic-terminal-settlement', registerJournalAtomicTerminalSettlementTests) +describe('journal-dispatch-reason-bound', registerJournalDispatchReasonBoundTests) +describe('journal-queued-rejection-batch', registerJournalQueuedRejectionBatchTests) +describe('journal-row-context-usage', registerJournalRowContextUsageTests) +describe('journal-subagent-item', registerJournalSubagentItemTests) +describe('journal-subagent-liveness', registerJournalSubagentLivenessTests) +describe('journal-submission-positions', registerJournalSubmissionPositionsTests) +describe('journal-submission-queued-link', registerJournalSubmissionQueuedLinkTests) +describe('journal-terminal-settlement-validation', registerJournalTerminalSettlementValidationTests) diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.spec.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.spec.ts new file mode 100644 index 00000000000..aa4868aee0f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.spec.ts @@ -0,0 +1,128 @@ +// A Stop's event and a Resume ride tombstones, but are no history: an app never receives them, +// and they never count as the content that makes a rebuilt or provider-backed epoch usable. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { startJournalRowFold } from './journal-open' +import { createJournalReducerState } from './journal-reducer' +import { buildJournalItemRow, journalRowBase } from './journal-row-builders' +import { + serializeJournalRow, + type AgentJournalEpochReason, + type JournalRow +} from './journal-row-schema' +import { + buildJournalQueueResumeRow, + buildJournalStopEventRow +} from './journal-stop-and-resume-rows' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalStopAndResumeRowsTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-s', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + const EPOCH = 'epoch-1' + + let root: string + const journals = createTrackedJournalOpener() + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-stop-event-rows-')) + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + /** One epoch's rows, folded as an open folds them. */ + function fold(rows: readonly JournalRow[]) { + const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH }) + for (const row of rows) { + folding.add({ seq: row.seq, rowJson: serializeJournalRow(row) }) + } + return folding.finish() + } + + /** An epoch row, then `after` built at the next sequences. */ + function epochWith( + reason: AgentJournalEpochReason, + after: readonly ('stop' | 'resume' | 'item')[] + ): JournalRow[] { + const state = createJournalReducerState(IDENTITY.sessionId, EPOCH) + const rows: JournalRow[] = [ + { + kind: 'epoch', + reason, + providerHandle: agentSessionJournalProviderHandle(IDENTITY), + ...journalRowBase(EPOCH, 1, 1, 1) + } + ] + for (const kind of after) { + const place = { state, seq: rows.length + 1, fence: 1, ts: rows.length + 1 } + rows.push( + kind === 'stop' + ? buildJournalStopEventRow({ ...place, event: { reason: 'user-stop', at: place.ts } }) + : kind === 'resume' + ? buildJournalQueueResumeRow(place) + : buildJournalItemRow({ + ...place, + identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + body: { kind: 'status', text: 'history' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + } + return rows + } + + describe("a Stop's event and a Resume", () => { + it('never reach an app: the batch projection skips them', async () => { + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + await journal.appendItem( + { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, + { kind: 'status', text: 'history' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + const cursor = journal.cursor() + await journal.appendStopEvent({ reason: 'user-stop' }, 1) + await journal.appendQueueResume(1) + const since = journal.readSince(cursor) + if (!since.ok) { + throw new Error(`expected rows, got reset ${since.reset}`) + } + expect(since.rows).toHaveLength(2) + const projected = projectJournalBatch({ + rows: since.rows, + snapshot: journal.snapshot(), + afterSequence: cursor.sequence + }) + expect(projected).toMatchObject({ ok: true, batch: { items: [], removedItemIds: [] } }) + }) + + // An older build founded `unreconcilable_prefix` epochs when it deleted history; this one opens + // such an epoch as it stands. + it('fold as history in an epoch an older build founded after deleting rows', () => { + expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume']))).toMatchObject({ + newer: null, + damage: null, + state: { lastSequence: 3 } + }) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts b/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts deleted file mode 100644 index 0785e156738..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-stop-and-resume-rows.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -// A Stop's event and a Resume ride tombstones, but are no history: an app never receives them, -// and they never count as the content that makes a rebuilt or provider-backed epoch usable. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { projectJournalBatch } from '../agent-session-wire/agent-session-journal-batch' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { startJournalRowFold } from './journal-open' -import { createJournalReducerState } from './journal-reducer' -import { buildJournalItemRow, journalRowBase } from './journal-row-builders' -import { - serializeJournalRow, - type AgentJournalEpochReason, - type JournalRow -} from './journal-row-schema' -import { - buildJournalQueueResumeRow, - buildJournalStopEventRow -} from './journal-stop-and-resume-rows' -import { - agentSessionJournalProviderHandle, - codexProviderHandle -} from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-s', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} -const EPOCH = 'epoch-1' - -let root: string -const journals = createTrackedJournalOpener() - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-stop-event-rows-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -/** One epoch's rows, folded as an open folds them. */ -function fold(rows: readonly JournalRow[]) { - const folding = startJournalRowFold({ sessionId: IDENTITY.sessionId, epoch: EPOCH }) - for (const row of rows) { - folding.add({ seq: row.seq, rowJson: serializeJournalRow(row) }) - } - return folding.finish() -} - -/** An epoch row, then `after` built at the next sequences. */ -function epochWith( - reason: AgentJournalEpochReason, - after: readonly ('stop' | 'resume' | 'item')[] -): JournalRow[] { - const state = createJournalReducerState(IDENTITY.sessionId, EPOCH) - const rows: JournalRow[] = [ - { - kind: 'epoch', - reason, - providerHandle: agentSessionJournalProviderHandle(IDENTITY), - ...journalRowBase(EPOCH, 1, 1, 1) - } - ] - for (const kind of after) { - const place = { state, seq: rows.length + 1, fence: 1, ts: rows.length + 1 } - rows.push( - kind === 'stop' - ? buildJournalStopEventRow({ ...place, event: { reason: 'user-stop', at: place.ts } }) - : kind === 'resume' - ? buildJournalQueueResumeRow(place) - : buildJournalItemRow({ - ...place, - identity: { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - body: { kind: 'status', text: 'history' }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ) - } - return rows -} - -describe("a Stop's event and a Resume", () => { - it('never reach an app: the batch projection skips them', async () => { - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - await journal.appendItem( - { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal: 0 }, - { kind: 'status', text: 'history' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - const cursor = journal.cursor() - await journal.appendStopEvent({ reason: 'user-stop' }, 1) - await journal.appendQueueResume(1) - const since = journal.readSince(cursor) - if (!since.ok) { - throw new Error(`expected rows, got reset ${since.reset}`) - } - expect(since.rows).toHaveLength(2) - const projected = projectJournalBatch({ - rows: since.rows, - snapshot: journal.snapshot(), - afterSequence: cursor.sequence - }) - expect(projected).toMatchObject({ ok: true, batch: { items: [], removedItemIds: [] } }) - }) - - // An older build founded `unreconcilable_prefix` epochs when it deleted history; this one opens - // such an epoch as it stands. - it('fold as history in an epoch an older build founded after deleting rows', () => { - expect(fold(epochWith('unreconcilable_prefix', ['stop', 'resume']))).toMatchObject({ - newer: null, - damage: null, - state: { lastSequence: 3 } - }) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-store-schema.spec.ts b/src/main/native-chat/agent-session-journal/journal-store-schema.spec.ts new file mode 100644 index 00000000000..b0a3d4c1e60 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-store-schema.spec.ts @@ -0,0 +1,250 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +// Two independent version axes, both fail closed. +// +// `PRAGMA user_version` the DB SHAPE, known before the first read +// the row's `v` field the row BODY shape, met during replay +// +// A newer build can change either alone, so both are needed. + +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' +import type { + AgentJournalItemBody, + AgentJournalItemIdentity, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import type Database from '../../sqlite/sync-database' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' +import type { AgentSessionJournal } from './journal-store' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + insertTestJournalRowJson, + SAVED_BY_NEWER_ORCA +} from './journal-host-database-test-support' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalStoreSchemaTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + + let root: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + function tick(): number { + clock += 1 + return clock + } + + 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 open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) + } + + async function withDatabase(run: (db: Database.Database) => void): Promise { + const opened = openTestJournalHostDatabase(root) + try { + run(opened.db) + } finally { + opened.close() + } + } + + async function digest(path: string): Promise { + return createHash('sha256') + .update(await readFile(path)) + .digest('hex') + } + + /** Appends a raw `row_json` the way a newer build or a bad write would leave it. */ + async function appendRawRow(epoch: string, seq: number, rowJson: string): Promise { + await withDatabase((db) => { + expect(liveTestJournalRows(db, IDENTITY.sessionId)[0]?.epoch).toBe(epoch) + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, rowJson) + }) + } + + /** A row only a newer build could have written. */ + function futureRow(epoch: string, seq: number): string { + return JSON.stringify({ + v: 99, + kind: 'item', + epoch, + seq, + fence: 1, + ts: 1, + itemId: 'future', + revision: 1, + body: { kind: 'status', text: 'from a newer build' } + }) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-schema-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('axis 1: the database shape', () => { + // A newer build's database: none of its chats open here, and nothing here writes to it. + it('refuses every chat in a database a newer build stamped, and writes nothing', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.close() + await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) + const before = await digest(journalDatabasePath(root)) + + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + // A chat the database never held is refused the same way rather than being founded. + await expect( + journals.open({ identity: { ...IDENTITY, sessionId: 'session-2' }, stateDirectory: root }) + ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + 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 a newer Orca's a chat whose tables a newer build changed", async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.close() + await withDatabase((db) => { + db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_v4') + db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) + }) + + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + }) + + // The only older version brought forward; 1 and 2 are refused (journal-database.spec.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, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.close() + await withDatabase((db) => db.pragma('user_version = 0')) + + const reopened = await open() + expect(reopened.snapshot().items).toHaveLength(1) + await reopened.close() + await withDatabase((db) => { + expect(db.pragma('user_version', { simple: true })).toBe(JOURNAL_DB_SCHEMA_VERSION) + }) + }) + }) + + describe('axis 2: the row body shape', () => { + it("refuses a journal holding a row from a newer build as a newer Orca's, and keeps that row", async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const epoch = journal.epoch + const nextSeq = journal.cursor().sequence + 1 + await journal.close() + await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) + + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + // Never skipped, never deleted: the row this build cannot read is still there. + await withDatabase((db) => { + const stored = liveTestJournalRows(db, IDENTITY.sessionId).find( + (row) => row.seq === nextSeq + ) + expect(stored?.rowJson).toContain('"v":99') + }) + }) + + it('refuses a journal holding a row that is not a row, and keeps that row', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const epoch = journal.epoch + const nextSeq = journal.cursor().sequence + 1 + await journal.close() + await appendRawRow(epoch, nextSeq, '{not json') + + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(open()).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + }) + } + await withDatabase((db) => { + const stored = liveTestJournalRows(db, IDENTITY.sessionId) + expect(stored.map((row) => row.seq)).toEqual([1, 2, nextSeq]) + expect(stored.at(-1)?.rowJson).toBe('{not json') + }) + }) + + it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const epoch = journal.epoch + const nextSeq = journal.cursor().sequence + 1 + await journal.close() + // `parseJournalRow` admits any string itemId, so replay must degrade a + // malformed percent key to an opaque id instead of throwing URIError. + await appendRawRow( + epoch, + nextSeq, + JSON.stringify({ + v: 1, + epoch, + seq: nextSeq, + fence: 1, + ts: 1, + kind: 'item', + itemId: '%', + revision: 1, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + }) + ) + + const reopened = await open() + expect(reopened.snapshot().items.some((entry) => entry.itemId === '%')).toBe(true) + }) + }) +} 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 deleted file mode 100644 index 75fd12db242..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-store-schema.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -// Two independent version axes, both fail closed. -// -// `PRAGMA user_version` the DB SHAPE, known before the first read -// the row's `v` field the row BODY shape, met during replay -// -// A newer build can change either alone, so both are needed. - -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' -import type { - AgentJournalItemBody, - AgentJournalItemIdentity, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import type Database from '../../sqlite/sync-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalDatabasePath } from './journal-host-database' -import type { AgentSessionJournal } from './journal-store' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - liveTestJournalRows, - insertTestJournalRowJson, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function tick(): number { - clock += 1 - return clock -} - -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 open(): Promise { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}` - }) -} - -async function withDatabase(run: (db: Database.Database) => void): Promise { - const opened = openTestJournalHostDatabase(root) - try { - run(opened.db) - } finally { - opened.close() - } -} - -async function digest(path: string): Promise { - return createHash('sha256') - .update(await readFile(path)) - .digest('hex') -} - -/** Appends a raw `row_json` the way a newer build or a bad write would leave it. */ -async function appendRawRow(epoch: string, seq: number, rowJson: string): Promise { - await withDatabase((db) => { - expect(liveTestJournalRows(db, IDENTITY.sessionId)[0]?.epoch).toBe(epoch) - insertTestJournalRowJson(db, IDENTITY.sessionId, seq, rowJson) - }) -} - -/** A row only a newer build could have written. */ -function futureRow(epoch: string, seq: number): string { - return JSON.stringify({ - v: 99, - kind: 'item', - epoch, - seq, - fence: 1, - ts: 1, - itemId: 'future', - revision: 1, - body: { kind: 'status', text: 'from a newer build' } - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-schema-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('axis 1: the database shape', () => { - // A newer build's database: none of its chats open here, and nothing here writes to it. - it('refuses every chat in a database a newer build stamped, and writes nothing', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withDatabase((db) => db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`)) - const before = await digest(journalDatabasePath(root)) - - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - // A chat the database never held is refused the same way rather than being founded. - await expect( - journals.open({ identity: { ...IDENTITY, sessionId: 'session-2' }, stateDirectory: root }) - ).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - 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 a newer Orca's a chat whose tables a newer build changed", async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withDatabase((db) => { - db.exec('ALTER TABLE journal_sessions RENAME TO journal_sessions_v4') - db.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) - }) - - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - }) - - // 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, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.close() - await withDatabase((db) => db.pragma('user_version = 0')) - - const reopened = await open() - expect(reopened.snapshot().items).toHaveLength(1) - await reopened.close() - await withDatabase((db) => { - expect(db.pragma('user_version', { simple: true })).toBe(JOURNAL_DB_SCHEMA_VERSION) - }) - }) -}) - -describe('axis 2: the row body shape', () => { - it("refuses a journal holding a row from a newer build as a newer Orca's, and keeps that row", async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const epoch = journal.epoch - const nextSeq = journal.cursor().sequence + 1 - await journal.close() - await appendRawRow(epoch, nextSeq, futureRow(epoch, nextSeq)) - - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - // Never skipped, never deleted: the row this build cannot read is still there. - await withDatabase((db) => { - const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === nextSeq) - expect(stored?.rowJson).toContain('"v":99') - }) - }) - - it('refuses a journal holding a row that is not a row, and keeps that row', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const epoch = journal.epoch - const nextSeq = journal.cursor().sequence + 1 - await journal.close() - await appendRawRow(epoch, nextSeq, '{not json') - - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(open()).rejects.toMatchObject({ - refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } - }) - } - await withDatabase((db) => { - const stored = liveTestJournalRows(db, IDENTITY.sessionId) - expect(stored.map((row) => row.seq)).toEqual([1, 2, nextSeq]) - expect(stored.at(-1)?.rowJson).toBe('{not json') - }) - }) - - it('reopens a journal holding an admitted malformed-percent item id without throwing', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const epoch = journal.epoch - const nextSeq = journal.cursor().sequence + 1 - await journal.close() - // `parseJournalRow` admits any string itemId, so replay must degrade a - // malformed percent key to an opaque id instead of throwing URIError. - await appendRawRow( - epoch, - nextSeq, - JSON.stringify({ - v: 1, - epoch, - seq: nextSeq, - fence: 1, - ts: 1, - kind: 'item', - itemId: '%', - revision: 1, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } - }) - ) - - const reopened = await open() - expect(reopened.snapshot().items.some((entry) => entry.itemId === '%')).toBe(true) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-store.spec.ts b/src/main/native-chat/agent-session-journal/journal-store.spec.ts new file mode 100644 index 00000000000..f773e8cf849 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-store.spec.ts @@ -0,0 +1,507 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { mkdtemp, readdir, 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 { + agentJournalItemKey, + boundJournalKeyComponent, + MAX_JOURNAL_KEY_COMPONENT_CHARS +} from '../../../shared/agent-session-journal-item-key' +import { + boundInlineText, + boundPayload, + DEFAULT_JOURNAL_PAYLOAD_LIMITS +} from './journal-payload-bounds' +import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' +import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store' +import type { openAgentSessionJournal } from './journal-store-factory' +import { + createTrackedJournalOpener, + openTestJournalHostDatabase, + liveTestJournalRows, + deleteTestJournalRow +} from './journal-host-database-test-support' +import type Database from '../../sqlite/sync-database' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalStoreTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + + let root: string + let clock = 1_000 + + function tick(): number { + clock += 1 + return clock + } + + 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 }] } + } + + const journals = createTrackedJournalOpener() + + async function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('sequences', () => { + it('assigns a contiguous sequence with no gaps or reuse under concurrent appends', async () => { + const journal = await open() + const results = await Promise.all( + Array.from({ length: 25 }, (_unused, index) => + journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + ) + ) + const sequences = results.map((result) => result.cursor.sequence) + expect(new Set(sequences).size).toBe(25) + expect(sequences.slice().sort((a, b) => a - b)).toEqual( + Array.from({ length: 25 }, (_unused, index) => index + 2) + ) + }) + + it('serializes revisions of one item so the last write wins deterministically', async () => { + const journal = await open() + const results = await Promise.all([ + journal.appendItem(item(0), body('a'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), + journal.appendItem(item(0), body('c'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ]) + expect(results.map((result) => result.revision)).toEqual([1, 2, 3]) + expect(journal.snapshot().items).toHaveLength(1) + expect(journal.snapshot().items[0]?.revision).toBe(3) + }) + + it('visits reduced items at their creation sequence without promoting an older revision', async () => { + const journal = await open() + await journal.appendItem(item(0), body('first'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const latest = await journal.appendItem(item(1), body('second'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('first revised'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const visited: { itemId: string; sequence: number }[] = [] + + journal.visitItems((itemId, sequence) => visited.push({ itemId, sequence })) + + expect(visited).toEqual([ + { itemId: agentJournalItemKey(item(0)), sequence: 2 }, + { itemId: latest.itemId, sequence: latest.cursor.sequence } + ]) + }) + + it('reads the live turn off reduced items, agreeing with the rendered snapshot', async () => { + const journal = await open() + const turnItem = (turnId: string): AgentJournalItemIdentity => ({ + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: `turn-lifecycle:${turnId}` + }) + const rendered = (): string | null => + activeStructuredAgentSessionTurnId(journal.snapshot().items) + const bothAgreeOn = async (turnId: string | null): Promise => { + expect(journal.runningTurn()?.turnId ?? null).toBe(turnId) + expect(rendered()).toBe(turnId) + } + + await journal.appendItem( + turnItem('turn-1'), + { kind: 'turn', turnId: 'turn-1', state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await journal.appendItem(item(0), body('work'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await bothAgreeOn('turn-1') + + // The completion is a revision, so it keeps the row's creation sequence rather than moving it. + await journal.appendItem( + turnItem('turn-1'), + { kind: 'turn', turnId: 'turn-1', state: 'completed' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await bothAgreeOn(null) + + await journal.appendItem( + turnItem('turn-2'), + { kind: 'turn', turnId: 'turn-2', state: 'running' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await bothAgreeOn('turn-2') + }) + + it('preserves an oversized identity and its raw digest-form mimic across reopen', async () => { + const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) + const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) + const identityFor = (turnId: string): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'thread-1', + turnId, + ordinal: 0 + }) + const oversizedIdentity = identityFor(oversizedTurnId) + const mimicIdentity = identityFor(digestFormMimic) + const journal = await open() + + const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(oversized.itemId).not.toBe(mimic.itemId) + expect([oversized.revision, mimic.revision]).toEqual([1, 1]) + + const reopened = await open() + expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ + body('oversized'), + body('mimic') + ]) + + await reopened.appendTombstone(oversizedIdentity, { fence: 1 }) + const afterTombstoneReopen = await open() + expect(afterTombstoneReopen.snapshot().items.map((entry) => entry.body)).toEqual([ + body('mimic') + ]) + }) + }) + + describe('fences', () => { + it('rejects an append from a writer behind the journal', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await expect( + journal.appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + ).rejects.toBeInstanceOf(AgentSessionJournalError) + }) + + it('keeps accepting appends after a rejected one', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal + .appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) + .catch(() => undefined) + await journal.appendItem(item(2), body('c'), { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('c')]) + }) + }) + + describe('replay', () => { + 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, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendItem(item(0), body('a2'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.appendTombstone(item(1), { fence: 1 }) + const live = journal.snapshot() + + const reopened = await open() + expect(reopened.snapshot()).toEqual(live) + }) + + it('serves a resume from a cursor and refuses one from a stale epoch', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const cursor = journal.cursor() + await journal.appendItem(item(1), body('b'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + + const resumed = journal.readSince(cursor) + expect(resumed.ok && resumed.rows).toHaveLength(1) + + await journal.rollEpoch('handle_forked', 2) + expect(journal.readSince(cursor)).toEqual({ ok: false, reset: 'epoch_changed' }) + }) + + it('rebuilds from a clean epoch after a rollover', async () => { + const journal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.rollEpoch('unreconcilable_prefix', 2) + expect(journal.snapshot().items).toHaveLength(0) + + const reopened = await open() + expect(reopened.epoch).toBe(journal.epoch) + expect(reopened.snapshot().items).toHaveLength(0) + }) + + it('refuses a journal with a gap and keeps every row', async () => { + const journal = await open() + for (let index = 0; index < 4; index += 1) { + await journal.appendItem(item(index), body(`m${index}`), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + await journal.close() + await withJournalDatabase(root, (db) => { + deleteTestJournalRow(db, IDENTITY.sessionId, 3) + }) + + await expect(open()).rejects.toMatchObject({ + refusal: { details: { reason: 'journalCorrupt' } } + }) + await withJournalDatabase(root, (db) => { + const rows = liveTestJournalRows(db, IDENTITY.sessionId) + expect(rows.map((row) => row.seq)).toEqual([1, 2, 4, 5]) + }) + }) + }) + + describe('bounds', () => { + it('marks a clipped payload instead of dropping bytes silently', () => { + const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 16 } + const bounded = boundPayload('x'.repeat(4_096), limits) + expect(bounded.truncated).toBe(true) + expect(bounded.head).toHaveLength(16) + expect(bounded.byteLength).toBe(4_096) + expect(boundInlineText('x'.repeat(4_096), limits).text).toContain('output truncated') + }) + + it('never splits a multi-byte character across the bound', () => { + const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 4 } + // Each character is three bytes, so a naive slice would land mid-sequence. + const bounded = boundPayload('日本語テスト', limits) + expect(bounded.head).toBe('日') + expect(Buffer.byteLength(bounded.head, 'utf8')).toBeLessThanOrEqual(4) + }) + + it('leaves a payload inside the bound untouched', () => { + const bounded = boundPayload('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS) + expect(bounded.truncated).toBe(false) + expect(bounded.head).toBe('small') + expect(boundInlineText('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS).text).toBe('small') + }) + }) + + describe('lifecycle batches', () => { + it('deduplicates concurrent submissions before appending a second row', async () => { + const journal = await open() + const input = { + settlementId: 'concurrent-settlement', + fence: 1, + mutations: [ + { + kind: 'item' as const, + identity: item(1), + body: body('settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + + const [first, replay] = await Promise.all([ + journal.appendLifecycleBatch(input), + journal.appendLifecycleBatch(input) + ]) + + expect([replay, first.sequence]).toEqual([first, 2]) + }) + + it('applies every mutation at one sequence and deduplicates a replay across reopen', async () => { + const journal = await open() + const turn: AgentJournalItemIdentity = { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' + } + await journal.appendItem( + turn, + { kind: 'status', text: 'working' }, + { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + + const settled = await journal.appendLifecycleBatch({ + settlementId: 'exit:turn-1', + fence: 1, + mutations: [ + { + kind: 'item', + identity: item(1), + body: body('tool settled'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { + kind: 'item', + identity: { provider: 'orca', clientMessageId: 'exit-status' }, + body: { kind: 'status', text: 'Provider exited' }, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }, + { kind: 'tombstone', identity: turn } + ] + }) + const atSettlement = journal + .snapshot() + .items.filter((entry) => entry.sequence === settled.sequence) + expect(atSettlement).toHaveLength(2) + expect( + journal + .snapshot() + .items.some((entry) => entry.body.kind === 'status' && entry.body.text === 'working') + ).toBe(false) + await journal.close() + + const reopened = await open() + const beforeReplay = reopened.cursor() + const replay = await reopened.appendLifecycleBatch({ + settlementId: 'exit:turn-1', + fence: 1, + mutations: [ + { + kind: 'item', + identity: item(9), + body: body('must not appear'), + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + }) + expect(replay).toEqual(beforeReplay) + expect( + reopened + .snapshot() + .items.some( + (entry) => + entry.body.kind === 'message' && + entry.body.blocks.some( + (block) => block.type === 'text' && block.text === 'must not appear' + ) + ) + ).toBe(false) + }) + }) + + describe('on-disk layout', () => { + it('keeps every chat of the state directory in its one database, and no per-chat file', async () => { + const journal: AgentSessionJournal = await open() + await journal.appendItem(item(0), body('a'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + 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) => { + expect(liveTestJournalRows(db, IDENTITY.sessionId)[1]?.rowJson).toContain('"kind":"item"') + expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toMatchObject({ + epoch: journal.epoch + }) + }) + }) + }) + + /** Opens the session database directly, so a case can stage a fault or read + * back what a commit actually stored. */ + async function withJournalDatabase( + stateDirectory: string, + run: (db: Database.Database) => void + ): Promise { + const opened = openTestJournalHostDatabase(stateDirectory) + try { + run(opened.db) + } finally { + opened.close() + } + } + + describe('what a handle found on disk when it opened', () => { + const submission = (clientMessageId: string) => ({ + clientMessageId, + payloadFingerprint: 'fp', + body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, + fence: 1, + handoverRecorded: true as const + }) + + it('names rows an earlier handle wrote, and never a row of a later epoch', async () => { + const earlier = await open() + await earlier.appendItem(item(1), body('one'), { + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await earlier.appendSubmission(submission('earlier')) + await earlier.close() + + const journal = await open() + const leftover = journal.submissions().find((entry) => entry.clientMessageId === 'earlier') + expect(journal.wroteBeforeOpen(leftover?.acceptedSequence)).toBe(true) + + // Sequences restart with an epoch, so a row accepted after it can sit below the open cursor. + await journal.replaceEpochItems('handle_forked', 1, []) + await journal.appendSubmission(submission('later')) + const later = journal.submissions().find((entry) => entry.clientMessageId === 'later') + expect(later?.acceptedSequence).toBeLessThanOrEqual(2) + expect(journal.wroteBeforeOpen(later?.acceptedSequence)).toBe(false) + }) + }) +} 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 deleted file mode 100644 index 6b2488c259a..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-store.test.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, readdir, 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 { - agentJournalItemKey, - boundJournalKeyComponent, - MAX_JOURNAL_KEY_COMPONENT_CHARS -} from '../../../shared/agent-session-journal-item-key' -import { - boundInlineText, - boundPayload, - DEFAULT_JOURNAL_PAYLOAD_LIMITS -} from './journal-payload-bounds' -import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-live-turn' -import { AgentSessionJournalError, type AgentSessionJournal } from './journal-store' -import type { openAgentSessionJournal } from './journal-store-factory' -import { - createTrackedJournalOpener, - openTestJournalHostDatabase, - liveTestJournalRows, - deleteTestJournalRow -} from './journal-host-database-test-support' -import type Database from '../../sqlite/sync-database' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -let root: string -let clock = 1_000 - -function tick(): number { - clock += 1 - return clock -} - -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 }] } -} - -const journals = createTrackedJournalOpener() - -async function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('sequences', () => { - it('assigns a contiguous sequence with no gaps or reuse under concurrent appends', async () => { - const journal = await open() - const results = await Promise.all( - Array.from({ length: 25 }, (_unused, index) => - journal.appendItem(item(index), body(`m${index}`), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - ) - ) - const sequences = results.map((result) => result.cursor.sequence) - expect(new Set(sequences).size).toBe(25) - expect(sequences.slice().sort((a, b) => a - b)).toEqual( - Array.from({ length: 25 }, (_unused, index) => index + 2) - ) - }) - - it('serializes revisions of one item so the last write wins deterministically', async () => { - const journal = await open() - const results = await Promise.all([ - journal.appendItem(item(0), body('a'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), - journal.appendItem(item(0), body('b'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }), - journal.appendItem(item(0), body('c'), { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ]) - expect(results.map((result) => result.revision)).toEqual([1, 2, 3]) - expect(journal.snapshot().items).toHaveLength(1) - expect(journal.snapshot().items[0]?.revision).toBe(3) - }) - - it('visits reduced items at their creation sequence without promoting an older revision', async () => { - const journal = await open() - await journal.appendItem(item(0), body('first'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const latest = await journal.appendItem(item(1), body('second'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(0), body('first revised'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const visited: { itemId: string; sequence: number }[] = [] - - journal.visitItems((itemId, sequence) => visited.push({ itemId, sequence })) - - expect(visited).toEqual([ - { itemId: agentJournalItemKey(item(0)), sequence: 2 }, - { itemId: latest.itemId, sequence: latest.cursor.sequence } - ]) - }) - - it('reads the live turn off reduced items, agreeing with the rendered snapshot', async () => { - const journal = await open() - const turnItem = (turnId: string): AgentJournalItemIdentity => ({ - provider: 'legacy', - agent: 'codex', - sessionId: 'session-1', - recordId: `turn-lifecycle:${turnId}` - }) - const rendered = (): string | null => - activeStructuredAgentSessionTurnId(journal.snapshot().items) - const bothAgreeOn = async (turnId: string | null): Promise => { - expect(journal.runningTurn()?.turnId ?? null).toBe(turnId) - expect(rendered()).toBe(turnId) - } - - await journal.appendItem( - turnItem('turn-1'), - { kind: 'turn', turnId: 'turn-1', state: 'running' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await journal.appendItem(item(0), body('work'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await bothAgreeOn('turn-1') - - // The completion is a revision, so it keeps the row's creation sequence rather than moving it. - await journal.appendItem( - turnItem('turn-1'), - { kind: 'turn', turnId: 'turn-1', state: 'completed' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await bothAgreeOn(null) - - await journal.appendItem( - turnItem('turn-2'), - { kind: 'turn', turnId: 'turn-2', state: 'running' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await bothAgreeOn('turn-2') - }) - - it('preserves an oversized identity and its raw digest-form mimic across reopen', async () => { - const oversizedTurnId = 'a'.repeat(MAX_JOURNAL_KEY_COMPONENT_CHARS + 1) - const digestFormMimic = boundJournalKeyComponent(oversizedTurnId) - const identityFor = (turnId: string): AgentJournalItemIdentity => ({ - provider: 'codex', - threadId: 'thread-1', - turnId, - ordinal: 0 - }) - const oversizedIdentity = identityFor(oversizedTurnId) - const mimicIdentity = identityFor(digestFormMimic) - const journal = await open() - - const oversized = await journal.appendItem(oversizedIdentity, body('oversized'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const mimic = await journal.appendItem(mimicIdentity, body('mimic'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - expect(oversized.itemId).not.toBe(mimic.itemId) - expect([oversized.revision, mimic.revision]).toEqual([1, 1]) - - const reopened = await open() - expect(reopened.snapshot().items.map((entry) => entry.body)).toEqual([ - body('oversized'), - body('mimic') - ]) - - await reopened.appendTombstone(oversizedIdentity, { fence: 1 }) - const afterTombstoneReopen = await open() - expect(afterTombstoneReopen.snapshot().items.map((entry) => entry.body)).toEqual([ - body('mimic') - ]) - }) -}) - -describe('fences', () => { - it('rejects an append from a writer behind the journal', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 7, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await expect( - journal.appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - ).rejects.toBeInstanceOf(AgentSessionJournalError) - }) - - it('keeps accepting appends after a rejected one', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 7, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal - .appendItem(item(1), body('b'), { fence: 6, turnScope: AGENT_JOURNAL_THREAD_SCOPE }) - .catch(() => undefined) - await journal.appendItem(item(2), body('c'), { - fence: 7, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - expect(journal.snapshot().items.map((entry) => entry.body)).toEqual([body('a'), body('c')]) - }) -}) - -describe('replay', () => { - 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, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(1), body('b'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendItem(item(0), body('a2'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.appendTombstone(item(1), { fence: 1 }) - const live = journal.snapshot() - - const reopened = await open() - expect(reopened.snapshot()).toEqual(live) - }) - - it('serves a resume from a cursor and refuses one from a stale epoch', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const cursor = journal.cursor() - await journal.appendItem(item(1), body('b'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - - const resumed = journal.readSince(cursor) - expect(resumed.ok && resumed.rows).toHaveLength(1) - - await journal.rollEpoch('handle_forked', 2) - expect(journal.readSince(cursor)).toEqual({ ok: false, reset: 'epoch_changed' }) - }) - - it('rebuilds from a clean epoch after a rollover', async () => { - const journal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.rollEpoch('unreconcilable_prefix', 2) - expect(journal.snapshot().items).toHaveLength(0) - - const reopened = await open() - expect(reopened.epoch).toBe(journal.epoch) - expect(reopened.snapshot().items).toHaveLength(0) - }) - - it('refuses a journal with a gap and keeps every row', async () => { - const journal = await open() - for (let index = 0; index < 4; index += 1) { - await journal.appendItem(item(index), body(`m${index}`), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - } - await journal.close() - await withJournalDatabase(root, (db) => { - deleteTestJournalRow(db, IDENTITY.sessionId, 3) - }) - - await expect(open()).rejects.toMatchObject({ - refusal: { details: { reason: 'journalCorrupt' } } - }) - await withJournalDatabase(root, (db) => { - const rows = liveTestJournalRows(db, IDENTITY.sessionId) - expect(rows.map((row) => row.seq)).toEqual([1, 2, 4, 5]) - }) - }) -}) - -describe('bounds', () => { - it('marks a clipped payload instead of dropping bytes silently', () => { - const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 16 } - const bounded = boundPayload('x'.repeat(4_096), limits) - expect(bounded.truncated).toBe(true) - expect(bounded.head).toHaveLength(16) - expect(bounded.byteLength).toBe(4_096) - expect(boundInlineText('x'.repeat(4_096), limits).text).toContain('output truncated') - }) - - it('never splits a multi-byte character across the bound', () => { - const limits = { ...DEFAULT_JOURNAL_PAYLOAD_LIMITS, inlineHeadBytes: 4 } - // Each character is three bytes, so a naive slice would land mid-sequence. - const bounded = boundPayload('日本語テスト', limits) - expect(bounded.head).toBe('日') - expect(Buffer.byteLength(bounded.head, 'utf8')).toBeLessThanOrEqual(4) - }) - - it('leaves a payload inside the bound untouched', () => { - const bounded = boundPayload('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS) - expect(bounded.truncated).toBe(false) - expect(bounded.head).toBe('small') - expect(boundInlineText('small', DEFAULT_JOURNAL_PAYLOAD_LIMITS).text).toBe('small') - }) -}) - -describe('lifecycle batches', () => { - it('deduplicates concurrent submissions before appending a second row', async () => { - const journal = await open() - const input = { - settlementId: 'concurrent-settlement', - fence: 1, - mutations: [ - { - kind: 'item' as const, - identity: item(1), - body: body('settled'), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ] - } - - const [first, replay] = await Promise.all([ - journal.appendLifecycleBatch(input), - journal.appendLifecycleBatch(input) - ]) - - expect([replay, first.sequence]).toEqual([first, 2]) - }) - - it('applies every mutation at one sequence and deduplicates a replay across reopen', async () => { - const journal = await open() - const turn: AgentJournalItemIdentity = { - provider: 'legacy', - agent: 'codex', - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' - } - await journal.appendItem( - turn, - { kind: 'status', text: 'working' }, - { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - - const settled = await journal.appendLifecycleBatch({ - settlementId: 'exit:turn-1', - fence: 1, - mutations: [ - { - kind: 'item', - identity: item(1), - body: body('tool settled'), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }, - { - kind: 'item', - identity: { provider: 'orca', clientMessageId: 'exit-status' }, - body: { kind: 'status', text: 'Provider exited' }, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }, - { kind: 'tombstone', identity: turn } - ] - }) - const atSettlement = journal - .snapshot() - .items.filter((entry) => entry.sequence === settled.sequence) - expect(atSettlement).toHaveLength(2) - expect( - journal - .snapshot() - .items.some((entry) => entry.body.kind === 'status' && entry.body.text === 'working') - ).toBe(false) - await journal.close() - - const reopened = await open() - const beforeReplay = reopened.cursor() - const replay = await reopened.appendLifecycleBatch({ - settlementId: 'exit:turn-1', - fence: 1, - mutations: [ - { - kind: 'item', - identity: item(9), - body: body('must not appear'), - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ] - }) - expect(replay).toEqual(beforeReplay) - expect( - reopened - .snapshot() - .items.some( - (entry) => - entry.body.kind === 'message' && - entry.body.blocks.some( - (block) => block.type === 'text' && block.text === 'must not appear' - ) - ) - ).toBe(false) - }) -}) - -describe('on-disk layout', () => { - it('keeps every chat of the state directory in its one database, and no per-chat file', async () => { - const journal: AgentSessionJournal = await open() - await journal.appendItem(item(0), body('a'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - 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) => { - expect(liveTestJournalRows(db, IDENTITY.sessionId)[1]?.rowJson).toContain('"kind":"item"') - expect(db.prepare('SELECT epoch FROM journal_sessions').get()).toMatchObject({ - epoch: journal.epoch - }) - }) - }) -}) - -/** Opens the session database directly, so a case can stage a fault or read - * back what a commit actually stored. */ -async function withJournalDatabase( - stateDirectory: string, - run: (db: Database.Database) => void -): Promise { - const opened = openTestJournalHostDatabase(stateDirectory) - try { - run(opened.db) - } finally { - opened.close() - } -} - -describe('what a handle found on disk when it opened', () => { - const submission = (clientMessageId: string) => ({ - clientMessageId, - payloadFingerprint: 'fp', - body: { kind: 'message' as const, role: 'user' as const, blocks: [] }, - fence: 1, - handoverRecorded: true as const - }) - - it('names rows an earlier handle wrote, and never a row of a later epoch', async () => { - const earlier = await open() - await earlier.appendItem(item(1), body('one'), { - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await earlier.appendSubmission(submission('earlier')) - await earlier.close() - - const journal = await open() - const leftover = journal.submissions().find((entry) => entry.clientMessageId === 'earlier') - expect(journal.wroteBeforeOpen(leftover?.acceptedSequence)).toBe(true) - - // Sequences restart with an epoch, so a row accepted after it can sit below the open cursor. - await journal.replaceEpochItems('handle_forked', 1, []) - await journal.appendSubmission(submission('later')) - const later = journal.submissions().find((entry) => entry.clientMessageId === 'later') - expect(later?.acceptedSequence).toBeLessThanOrEqual(2) - expect(journal.wroteBeforeOpen(later?.acceptedSequence)).toBe(false) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-item.spec.ts b/src/main/native-chat/agent-session-journal/journal-subagent-item.spec.ts new file mode 100644 index 00000000000..71191e8771b --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-subagent-item.spec.ts @@ -0,0 +1,159 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { isSubagentBlock, type NativeChatSubagentBlock } from '../../../shared/native-chat-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import type { openAgentSessionJournal } from './journal-store-factory' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { + journaledSubagent, + subagentJournalBody, + subagentJournalItemIdentity +} from './journal-subagent-item' + +export function registerJournalSubagentItemTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + + function child(overrides: Partial = {}): NativeChatSubagentBlock { + return { + type: 'subagent', + id: 'child-1', + label: 'Audit', + state: 'working', + groupId: 'turn-1', + run: 'call-1', + startedAt: 10, + ...overrides + } + } + + let root: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + async function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-subagent-item-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('a subagent’s own journal row', () => { + it('is one key per child, whatever its run or state', () => { + expect(subagentJournalItemIdentity('child-1')).toEqual(subagentJournalItemIdentity('child-1')) + expect(subagentJournalItemIdentity('child-1')).not.toEqual( + subagentJournalItemIdentity('child-2') + ) + // Two long ids sharing a head stay two rows. + const head = 'x'.repeat(600) + expect(subagentJournalItemIdentity(`${head}a`)).not.toEqual( + subagentJournalItemIdentity(`${head}b`) + ) + }) + + it('carries the block beside the sentence an older client draws instead', () => { + const body = subagentJournalBody(child()) + expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true) + const broken = { ...body, blocks: [{ type: 'subagent', id: 'child-1', label: 'Audit' }] } + expect(AgentJournalItemBodySchema.safeParse(broken).success).toBe(false) + expect(body.blocks).toEqual([{ type: 'text', text: 'Kicked off subagent Audit' }, child()]) + const stopped = subagentJournalBody( + child({ state: 'stopped', settledAt: 20, outcomeBasis: 'stop-acknowledged' }) + ) + expect(stopped.blocks[0]).toEqual({ type: 'text', text: 'Ran subagent Audit (stopped)' }) + }) + + it('bounds the provider strings it stores', () => { + const body = subagentJournalBody(child({ label: '😀'.repeat(400), id: 'i'.repeat(900) })) + const block = body.blocks.find(isSubagentBlock)! + expect(block.label.length).toBeLessThanOrEqual(512) + expect(block.label.at(-1)).toBe('…') + // No lone surrogate before the ellipsis. + expect(block.label.at(-2)).toMatch(/[\uDC00-\uDFFF]/) + expect(block.id.length).toBe(512) + }) + + it('is read back by key, and every revision stays the row its spawn created', async () => { + const journal = await open() + await journal.appendItem( + subagentJournalItemIdentity('earlier'), + subagentJournalBody(child({ id: 'earlier' })), + { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await journal.appendItem( + subagentJournalItemIdentity('child-1'), + subagentJournalBody(child()), + { + fence: 0, + turnScope: { kind: 'turn', turnItemId: 'turn-1' } + } + ) + const spawned = journal.snapshot().items.at(-1)! + await journal.appendItem( + subagentJournalItemIdentity('child-1'), + subagentJournalBody(child({ state: 'completed', run: 'call-2', settledAt: 30 })), + { fence: 0, turnScope: { kind: 'turn', turnItemId: 'turn-9' } } + ) + + expect(journaledSubagent(journal, 'child-1')).toMatchObject({ + state: 'completed', + run: 'call-2' + }) + expect(journaledSubagent(journal, 'never-spawned')).toBeNull() + const revised = journal.item(agentJournalItemKey(subagentJournalItemIdentity('child-1')))! + expect(revised.revision).toBe(2) + expect(revised.sequence).toBe(spawned.sequence) + expect(revised.turnScope).toEqual({ kind: 'turn', turnItemId: 'turn-1' }) + }) + + it('reads unverifiable once a host that died left it working, twin and all', async () => { + const live = await open() + await live.appendItem(subagentJournalItemIdentity('child-1'), subagentJournalBody(child()), { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await live.close() + + const reopened = await open() + const row = reopened.snapshot().items.at(-1)! + expect(row.recovered).toBe(true) + expect(row.body).toMatchObject({ + blocks: [ + { type: 'text', text: 'Ran subagent Audit (unverifiable)' }, + { type: 'subagent', id: 'child-1', state: 'unverifiable', run: 'call-1' } + ] + }) + // Contact was lost at an unknown moment, so no run length is invented. + expect(row.body.kind === 'message' && row.body.blocks[1]).not.toHaveProperty('settledAt') + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-item.test.ts b/src/main/native-chat/agent-session-journal/journal-subagent-item.test.ts deleted file mode 100644 index cf464c4445b..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-subagent-item.test.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { isSubagentBlock, type NativeChatSubagentBlock } from '../../../shared/native-chat-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { - journaledSubagent, - subagentJournalBody, - subagentJournalItemIdentity -} from './journal-subagent-item' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -function child(overrides: Partial = {}): NativeChatSubagentBlock { - return { - type: 'subagent', - id: 'child-1', - label: 'Audit', - state: 'working', - groupId: 'turn-1', - run: 'call-1', - startedAt: 10, - ...overrides - } -} - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -async function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => ++clock, - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-subagent-item-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('a subagent’s own journal row', () => { - it('is one key per child, whatever its run or state', () => { - expect(subagentJournalItemIdentity('child-1')).toEqual(subagentJournalItemIdentity('child-1')) - expect(subagentJournalItemIdentity('child-1')).not.toEqual( - subagentJournalItemIdentity('child-2') - ) - // Two long ids sharing a head stay two rows. - const head = 'x'.repeat(600) - expect(subagentJournalItemIdentity(`${head}a`)).not.toEqual( - subagentJournalItemIdentity(`${head}b`) - ) - }) - - it('carries the block beside the sentence an older client draws instead', () => { - const body = subagentJournalBody(child()) - expect(AgentJournalItemBodySchema.safeParse(body).success).toBe(true) - const broken = { ...body, blocks: [{ type: 'subagent', id: 'child-1', label: 'Audit' }] } - expect(AgentJournalItemBodySchema.safeParse(broken).success).toBe(false) - expect(body.blocks).toEqual([{ type: 'text', text: 'Kicked off subagent Audit' }, child()]) - const stopped = subagentJournalBody( - child({ state: 'stopped', settledAt: 20, outcomeBasis: 'stop-acknowledged' }) - ) - expect(stopped.blocks[0]).toEqual({ type: 'text', text: 'Ran subagent Audit (stopped)' }) - }) - - it('bounds the provider strings it stores', () => { - const body = subagentJournalBody(child({ label: '😀'.repeat(400), id: 'i'.repeat(900) })) - const block = body.blocks.find(isSubagentBlock)! - expect(block.label.length).toBeLessThanOrEqual(512) - expect(block.label.at(-1)).toBe('…') - // No lone surrogate before the ellipsis. - expect(block.label.at(-2)).toMatch(/[\uDC00-\uDFFF]/) - expect(block.id.length).toBe(512) - }) - - it('is read back by key, and every revision stays the row its spawn created', async () => { - const journal = await open() - await journal.appendItem( - subagentJournalItemIdentity('earlier'), - subagentJournalBody(child({ id: 'earlier' })), - { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ) - await journal.appendItem(subagentJournalItemIdentity('child-1'), subagentJournalBody(child()), { - fence: 0, - turnScope: { kind: 'turn', turnItemId: 'turn-1' } - }) - const spawned = journal.snapshot().items.at(-1)! - await journal.appendItem( - subagentJournalItemIdentity('child-1'), - subagentJournalBody(child({ state: 'completed', run: 'call-2', settledAt: 30 })), - { fence: 0, turnScope: { kind: 'turn', turnItemId: 'turn-9' } } - ) - - expect(journaledSubagent(journal, 'child-1')).toMatchObject({ - state: 'completed', - run: 'call-2' - }) - expect(journaledSubagent(journal, 'never-spawned')).toBeNull() - const revised = journal.item(agentJournalItemKey(subagentJournalItemIdentity('child-1')))! - expect(revised.revision).toBe(2) - expect(revised.sequence).toBe(spawned.sequence) - expect(revised.turnScope).toEqual({ kind: 'turn', turnItemId: 'turn-1' }) - }) - - it('reads unverifiable once a host that died left it working, twin and all', async () => { - const live = await open() - await live.appendItem(subagentJournalItemIdentity('child-1'), subagentJournalBody(child()), { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await live.close() - - const reopened = await open() - const row = reopened.snapshot().items.at(-1)! - expect(row.recovered).toBe(true) - expect(row.body).toMatchObject({ - blocks: [ - { type: 'text', text: 'Ran subagent Audit (unverifiable)' }, - { type: 'subagent', id: 'child-1', state: 'unverifiable', run: 'call-1' } - ] - }) - // Contact was lost at an unknown moment, so no run length is invented. - expect(row.body.kind === 'message' && row.body.blocks[1]).not.toHaveProperty('settledAt') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.spec.ts b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.spec.ts new file mode 100644 index 00000000000..ef945180c60 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-subagent-liveness.spec.ts @@ -0,0 +1,381 @@ +import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalItemBody, + AgentJournalRenderItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { backgroundTaskFallbackText } from '../../../shared/native-chat-background-task-row' +import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../../shared/native-chat-types' +import type { + NativeChatBackgroundTaskBlock, + NativeChatSubagentEntry +} from '../../../shared/native-chat-types' +import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary' +import type { openAgentSessionJournal } from './journal-store-factory' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { lostLiveWorkJournalBody, staleSubagentRosterRevisions } from './journal-subagent-liveness' +import { endedUnseenMessageBody } from './journal-terminal-settlement' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalSubagentLivenessTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + + const GROUP_ID = 'thread-1:turn-1' + + let root: string + let clock = 1_000 + + function tick(): number { + clock += 1 + return clock + } + + const journals = createTrackedJournalOpener() + + async function open(overrides: Partial[0]> = {}) { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: tick, + mintEpoch: () => `epoch-${clock}`, + ...overrides + }) + } + + /** A grouped row as an earlier build wrote it: the structured block plus its twin. */ + function rosterRow(agents: NativeChatSubagentEntry[]) { + return { + identity: { provider: 'orca' as const, clientMessageId: `codex-subagents:${GROUP_ID}` }, + body: { + kind: 'message' as const, + role: 'system' as const, + blocks: [ + { type: 'text' as const, text: subagentGroupFallbackText(agents) }, + { type: 'subagent-group' as const, groupId: GROUP_ID, agents } + ] + } + } + } + + function backgroundTaskBlock( + overrides: Partial = {} + ): NativeChatBackgroundTaskBlock { + return { + type: 'background-task', + taskId: 'task-1', + kind: 'command', + label: 'sleep 20', + state: 'working', + startedAt: 10, + ...overrides + } + } + + function backgroundTaskRow(block = backgroundTaskBlock()) { + return { + identity: { + provider: 'orca' as const, + clientMessageId: `claude-background-task:${block.taskId}` + }, + body: { + kind: 'message' as const, + role: 'system' as const, + blocks: [{ type: 'text' as const, text: backgroundTaskFallbackText(block) }, block] + } + } + } + + function renderItem(agents: NativeChatSubagentEntry[]): AgentJournalRenderItem { + const row = rosterRow(agents) + return { + itemId: agentJournalItemKey(row.identity), + revision: 1, + body: row.body, + sequence: 2, + observedAt: 1 + } + } + + function rosterOf(body: AgentJournalRenderItem['body']): NativeChatSubagentEntry[] { + return body.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : [] + } + + function taskOf(body: AgentJournalRenderItem['body']): NativeChatBackgroundTaskBlock | undefined { + return body.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined + } + + function twinOf(body: AgentJournalRenderItem['body']): string | undefined { + return body.kind === 'message' + ? body.blocks.find((block) => block.type === 'text')?.text + : undefined + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-subagents-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('staleSubagentRosterRevisions', () => { + it('settles a child the previous host left working, and moves the twin with it', () => { + const revisions = staleSubagentRosterRevisions([ + renderItem([ + { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, + { id: 'b', label: 'read_package', state: 'completed', startedAt: 10, settledAt: 20 } + ]) + ]) + + expect(revisions).toHaveLength(1) + expect(rosterOf(revisions[0]!.body)).toMatchObject([ + { id: 'a', state: 'unverifiable' }, + { id: 'b', state: 'completed' } + ]) + // Mobile reads only this sentence, so it may not go on saying `Kicked off`. + expect(twinOf(revisions[0]!.body)).toBe('Ran 2 subagents (1 unverifiable)') + }) + + it('settles a background task the previous host left live, and moves the twin with it', () => { + const row = backgroundTaskRow() + const revisions = staleSubagentRosterRevisions([ + { + itemId: agentJournalItemKey(row.identity), + revision: 1, + body: row.body, + sequence: 2, + observedAt: 1 + } + ]) + + expect(revisions).toHaveLength(1) + expect(taskOf(revisions[0]!.body)).toMatchObject({ taskId: 'task-1', state: 'unverifiable' }) + expect(twinOf(revisions[0]!.body)).toBe('Background command "sleep 20" stopped reporting') + }) + + // The child stopped being observable at an unknown moment. A stamp taken now + // would report the time the app was down as how long the child ran. + it('records no terminal timestamp for a child whose run length is unknown', () => { + const revisions = staleSubagentRosterRevisions([ + renderItem([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) + ]) + + expect(rosterOf(revisions[0]!.body)[0]).not.toHaveProperty('settledAt') + }) + + it('records no terminal timestamp for a background task whose run length is unknown', () => { + const row = backgroundTaskRow(backgroundTaskBlock({ settledAt: 20 })) + const revisions = staleSubagentRosterRevisions([ + { + itemId: agentJournalItemKey(row.identity), + revision: 1, + body: row.body, + sequence: 2, + observedAt: 1 + } + ]) + + expect(taskOf(revisions[0]!.body)).not.toHaveProperty('settledAt') + }) + + it('owes nothing for a roster whose children all settled', () => { + expect( + staleSubagentRosterRevisions([ + renderItem([{ id: 'a', label: 'read', state: 'completed', settledAt: 20 }]) + ]) + ).toEqual([]) + }) + + it('leaves rows that carry no roster alone', () => { + expect( + staleSubagentRosterRevisions([ + { + itemId: 'orca:plain', + revision: 1, + body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, + sequence: 2, + observedAt: 1 + } + ]) + ).toEqual([]) + }) + + // Appending under a fresh identity would add a second row rather than revise + // the one on disk, so an unaddressable key is left exactly as it is. + it('skips a row whose key cannot be parsed back to its identity', () => { + expect( + staleSubagentRosterRevisions([ + { ...renderItem([{ id: 'a', label: 'r', state: 'working' }]), itemId: 'not-a-key' } + ]) + ).toEqual([]) + }) + }) + + describe('journal reopen after the writing host is gone', () => { + it('writes composed roster, task, reasoning and text-twin corrections as one settlement step', async () => { + const journal = await open() + const roster = rosterRow([ + { id: 'working', label: 'read', state: 'working' }, + { id: 'done', label: 'write', state: 'completed', settledAt: 20 } + ]) + const task = backgroundTaskRow() + if (roster.body.kind !== 'message') { + throw new Error('a subagent roster is a message row') + } + const body: AgentJournalItemBody = { + ...roster.body, + state: 'running', + blocks: [...roster.body.blocks, ...task.body.blocks] + } + await journal.appendItem(roster.identity, body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const published: ReturnType[] = [] + journal.observeCommits(() => published.push(journal.snapshot())) + const resolve = () => { + const current = journal.itemBody(agentJournalItemKey(roster.identity)) + const lost = current && lostLiveWorkJournalBody(current) + if (!lost) { + return [] + } + return [ + { + kind: 'item' as const, + identity: roster.identity, + body: endedUnseenMessageBody(lost) ?? lost, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ] + } + await journal.appendSteps([ + { + kind: 'settlement', + batch: { settlementId: 'composed:0', fence: 0, recovered: true, resolve } + } + ]) + const settled = journal.snapshot() + const next = settled.items.at(-1)!.body + expect(next).toMatchObject({ state: 'completed' }) + expect(rosterOf(next)).toMatchObject([ + { state: 'unverifiable' }, + { state: 'completed', settledAt: 20 } + ]) + expect(taskOf(next)).toMatchObject({ state: 'unverifiable' }) + expect( + next.kind === 'message' && + next.blocks.filter((block) => block.type === 'text').map((block) => block.text) + ).toEqual([ + 'Ran 2 subagents (1 unverifiable)', + 'Background command "sleep 20" stopped reporting' + ]) + expect(published).toEqual([settled]) + await journal.appendSteps([ + { + kind: 'settlement', + batch: { settlementId: 'composed:0', fence: 0, recovered: true, resolve } + } + ]) + expect(journal.snapshot()).toEqual(settled) + expect(published).toEqual([settled]) + }) + + it('settles a persisted working roster to unverifiable, while the live row still reads working', async () => { + const live = await open() + const row = rosterRow([ + { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, + { id: 'b', label: 'read_package', state: 'working', startedAt: 10 } + ]) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + + // Still the writing host: it can see the children, so the row says so. + const beforeRestart = live.snapshot().items.at(-1)! + expect(rosterOf(beforeRestart.body)).toMatchObject([ + { state: 'working' }, + { state: 'working' } + ]) + expect(twinOf(beforeRestart.body)).toBe('Kicked off 2 subagents') + + // The host dies without ever settling them — no `ended`, so no session sweep. + await live.close() + clock += 3_600_000 + + const reopened = await open() + const afterRestart = reopened.snapshot().items.at(-1)! + expect(afterRestart.itemId).toBe(beforeRestart.itemId) + expect(afterRestart.recovered).toBe(true) + expect(rosterOf(afterRestart.body)).toMatchObject([ + { id: 'a', state: 'unverifiable' }, + { id: 'b', state: 'unverifiable' } + ]) + expect(twinOf(afterRestart.body)).toBe('Ran 2 subagents (2 unverifiable)') + }) + + it('revises the row in place rather than appending a second one', async () => { + const live = await open() + const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const before = live.snapshot().items.length + await live.close() + + const reopened = await open() + expect(reopened.snapshot().items).toHaveLength(before) + expect(reopened.snapshot().items.at(-1)?.revision).toBe(2) + }) + + it('settles a persisted working background task to unverifiable', async () => { + const live = await open() + const row = backgroundTaskRow() + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const beforeRestart = live.snapshot().items.at(-1)! + expect(taskOf(beforeRestart.body)).toMatchObject({ state: 'working' }) + expect(twinOf(beforeRestart.body)).toBe('Started background command "sleep 20"') + await live.close() + + const reopened = await open() + const afterRestart = reopened.snapshot().items.at(-1)! + expect(afterRestart.itemId).toBe(beforeRestart.itemId) + expect(taskOf(afterRestart.body)).toMatchObject({ state: 'unverifiable' }) + expect(twinOf(afterRestart.body)).toBe('Background command "sleep 20" stopped reporting') + }) + + it('writes nothing on a second reopen once every child is settled', async () => { + const live = await open() + const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) + await live.appendItem(row.identity, row.body, { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await live.close() + + const once = await open() + const revision = once.snapshot().items.at(-1)?.revision + await once.close() + + const twice = await open() + expect(twice.snapshot().items.at(-1)?.revision).toBe(revision) + }) + }) +} 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 deleted file mode 100644 index 47d689fc13f..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-subagent-liveness.test.ts +++ /dev/null @@ -1,376 +0,0 @@ -import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentJournalItemBody, - AgentJournalRenderItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { backgroundTaskFallbackText } from '../../../shared/native-chat-background-task-row' -import { isBackgroundTaskBlock, isSubagentGroupBlock } from '../../../shared/native-chat-types' -import type { - NativeChatBackgroundTaskBlock, - NativeChatSubagentEntry -} from '../../../shared/native-chat-types' -import { subagentGroupFallbackText } from '../../../shared/native-chat-subagent-summary' -import type { openAgentSessionJournal } from './journal-store-factory' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { lostLiveWorkJournalBody, staleSubagentRosterRevisions } from './journal-subagent-liveness' -import { endedUnseenMessageBody } from './journal-terminal-settlement' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} - -const GROUP_ID = 'thread-1:turn-1' - -let root: string -let clock = 1_000 - -function tick(): number { - clock += 1 - return clock -} - -const journals = createTrackedJournalOpener() - -async function open(overrides: Partial[0]> = {}) { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}`, - ...overrides - }) -} - -/** A grouped row as an earlier build wrote it: the structured block plus its twin. */ -function rosterRow(agents: NativeChatSubagentEntry[]) { - return { - identity: { provider: 'orca' as const, clientMessageId: `codex-subagents:${GROUP_ID}` }, - body: { - kind: 'message' as const, - role: 'system' as const, - blocks: [ - { type: 'text' as const, text: subagentGroupFallbackText(agents) }, - { type: 'subagent-group' as const, groupId: GROUP_ID, agents } - ] - } - } -} - -function backgroundTaskBlock( - overrides: Partial = {} -): NativeChatBackgroundTaskBlock { - return { - type: 'background-task', - taskId: 'task-1', - kind: 'command', - label: 'sleep 20', - state: 'working', - startedAt: 10, - ...overrides - } -} - -function backgroundTaskRow(block = backgroundTaskBlock()) { - return { - identity: { - provider: 'orca' as const, - clientMessageId: `claude-background-task:${block.taskId}` - }, - body: { - kind: 'message' as const, - role: 'system' as const, - blocks: [{ type: 'text' as const, text: backgroundTaskFallbackText(block) }, block] - } - } -} - -function renderItem(agents: NativeChatSubagentEntry[]): AgentJournalRenderItem { - const row = rosterRow(agents) - return { - itemId: agentJournalItemKey(row.identity), - revision: 1, - body: row.body, - sequence: 2, - observedAt: 1 - } -} - -function rosterOf(body: AgentJournalRenderItem['body']): NativeChatSubagentEntry[] { - return body.kind === 'message' ? (body.blocks.find(isSubagentGroupBlock)?.agents ?? []) : [] -} - -function taskOf(body: AgentJournalRenderItem['body']): NativeChatBackgroundTaskBlock | undefined { - return body.kind === 'message' ? body.blocks.find(isBackgroundTaskBlock) : undefined -} - -function twinOf(body: AgentJournalRenderItem['body']): string | undefined { - return body.kind === 'message' - ? body.blocks.find((block) => block.type === 'text')?.text - : undefined -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-subagents-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('staleSubagentRosterRevisions', () => { - it('settles a child the previous host left working, and moves the twin with it', () => { - const revisions = staleSubagentRosterRevisions([ - renderItem([ - { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, - { id: 'b', label: 'read_package', state: 'completed', startedAt: 10, settledAt: 20 } - ]) - ]) - - expect(revisions).toHaveLength(1) - expect(rosterOf(revisions[0]!.body)).toMatchObject([ - { id: 'a', state: 'unverifiable' }, - { id: 'b', state: 'completed' } - ]) - // Mobile reads only this sentence, so it may not go on saying `Kicked off`. - expect(twinOf(revisions[0]!.body)).toBe('Ran 2 subagents (1 unverifiable)') - }) - - it('settles a background task the previous host left live, and moves the twin with it', () => { - const row = backgroundTaskRow() - const revisions = staleSubagentRosterRevisions([ - { - itemId: agentJournalItemKey(row.identity), - revision: 1, - body: row.body, - sequence: 2, - observedAt: 1 - } - ]) - - expect(revisions).toHaveLength(1) - expect(taskOf(revisions[0]!.body)).toMatchObject({ taskId: 'task-1', state: 'unverifiable' }) - expect(twinOf(revisions[0]!.body)).toBe('Background command "sleep 20" stopped reporting') - }) - - // The child stopped being observable at an unknown moment. A stamp taken now - // would report the time the app was down as how long the child ran. - it('records no terminal timestamp for a child whose run length is unknown', () => { - const revisions = staleSubagentRosterRevisions([ - renderItem([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - ]) - - expect(rosterOf(revisions[0]!.body)[0]).not.toHaveProperty('settledAt') - }) - - it('records no terminal timestamp for a background task whose run length is unknown', () => { - const row = backgroundTaskRow(backgroundTaskBlock({ settledAt: 20 })) - const revisions = staleSubagentRosterRevisions([ - { - itemId: agentJournalItemKey(row.identity), - revision: 1, - body: row.body, - sequence: 2, - observedAt: 1 - } - ]) - - expect(taskOf(revisions[0]!.body)).not.toHaveProperty('settledAt') - }) - - it('owes nothing for a roster whose children all settled', () => { - expect( - staleSubagentRosterRevisions([ - renderItem([{ id: 'a', label: 'read', state: 'completed', settledAt: 20 }]) - ]) - ).toEqual([]) - }) - - it('leaves rows that carry no roster alone', () => { - expect( - staleSubagentRosterRevisions([ - { - itemId: 'orca:plain', - revision: 1, - body: { kind: 'message', role: 'assistant', blocks: [{ type: 'text', text: 'hi' }] }, - sequence: 2, - observedAt: 1 - } - ]) - ).toEqual([]) - }) - - // Appending under a fresh identity would add a second row rather than revise - // the one on disk, so an unaddressable key is left exactly as it is. - it('skips a row whose key cannot be parsed back to its identity', () => { - expect( - staleSubagentRosterRevisions([ - { ...renderItem([{ id: 'a', label: 'r', state: 'working' }]), itemId: 'not-a-key' } - ]) - ).toEqual([]) - }) -}) - -describe('journal reopen after the writing host is gone', () => { - it('writes composed roster, task, reasoning and text-twin corrections as one settlement step', async () => { - const journal = await open() - const roster = rosterRow([ - { id: 'working', label: 'read', state: 'working' }, - { id: 'done', label: 'write', state: 'completed', settledAt: 20 } - ]) - const task = backgroundTaskRow() - if (roster.body.kind !== 'message') { - throw new Error('a subagent roster is a message row') - } - const body: AgentJournalItemBody = { - ...roster.body, - state: 'running', - blocks: [...roster.body.blocks, ...task.body.blocks] - } - await journal.appendItem(roster.identity, body, { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const published: ReturnType[] = [] - journal.observeCommits(() => published.push(journal.snapshot())) - const resolve = () => { - const current = journal.itemBody(agentJournalItemKey(roster.identity)) - const lost = current && lostLiveWorkJournalBody(current) - if (!lost) { - return [] - } - return [ - { - kind: 'item' as const, - identity: roster.identity, - body: endedUnseenMessageBody(lost) ?? lost, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ] - } - await journal.appendSteps([ - { - kind: 'settlement', - batch: { settlementId: 'composed:0', fence: 0, recovered: true, resolve } - } - ]) - const settled = journal.snapshot() - const next = settled.items.at(-1)!.body - expect(next).toMatchObject({ state: 'completed' }) - expect(rosterOf(next)).toMatchObject([ - { state: 'unverifiable' }, - { state: 'completed', settledAt: 20 } - ]) - expect(taskOf(next)).toMatchObject({ state: 'unverifiable' }) - expect( - next.kind === 'message' && - next.blocks.filter((block) => block.type === 'text').map((block) => block.text) - ).toEqual([ - 'Ran 2 subagents (1 unverifiable)', - 'Background command "sleep 20" stopped reporting' - ]) - expect(published).toEqual([settled]) - await journal.appendSteps([ - { - kind: 'settlement', - batch: { settlementId: 'composed:0', fence: 0, recovered: true, resolve } - } - ]) - expect(journal.snapshot()).toEqual(settled) - expect(published).toEqual([settled]) - }) - - it('settles a persisted working roster to unverifiable, while the live row still reads working', async () => { - const live = await open() - const row = rosterRow([ - { id: 'a', label: 'read_readme', state: 'working', startedAt: 10 }, - { id: 'b', label: 'read_package', state: 'working', startedAt: 10 } - ]) - await live.appendItem(row.identity, row.body, { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - - // Still the writing host: it can see the children, so the row says so. - const beforeRestart = live.snapshot().items.at(-1)! - expect(rosterOf(beforeRestart.body)).toMatchObject([{ state: 'working' }, { state: 'working' }]) - expect(twinOf(beforeRestart.body)).toBe('Kicked off 2 subagents') - - // The host dies without ever settling them — no `ended`, so no session sweep. - await live.close() - clock += 3_600_000 - - const reopened = await open() - const afterRestart = reopened.snapshot().items.at(-1)! - expect(afterRestart.itemId).toBe(beforeRestart.itemId) - expect(afterRestart.recovered).toBe(true) - expect(rosterOf(afterRestart.body)).toMatchObject([ - { id: 'a', state: 'unverifiable' }, - { id: 'b', state: 'unverifiable' } - ]) - expect(twinOf(afterRestart.body)).toBe('Ran 2 subagents (2 unverifiable)') - }) - - it('revises the row in place rather than appending a second one', async () => { - const live = await open() - const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const before = live.snapshot().items.length - await live.close() - - const reopened = await open() - expect(reopened.snapshot().items).toHaveLength(before) - expect(reopened.snapshot().items.at(-1)?.revision).toBe(2) - }) - - it('settles a persisted working background task to unverifiable', async () => { - const live = await open() - const row = backgroundTaskRow() - await live.appendItem(row.identity, row.body, { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const beforeRestart = live.snapshot().items.at(-1)! - expect(taskOf(beforeRestart.body)).toMatchObject({ state: 'working' }) - expect(twinOf(beforeRestart.body)).toBe('Started background command "sleep 20"') - await live.close() - - const reopened = await open() - const afterRestart = reopened.snapshot().items.at(-1)! - expect(afterRestart.itemId).toBe(beforeRestart.itemId) - expect(taskOf(afterRestart.body)).toMatchObject({ state: 'unverifiable' }) - expect(twinOf(afterRestart.body)).toBe('Background command "sleep 20" stopped reporting') - }) - - it('writes nothing on a second reopen once every child is settled', async () => { - const live = await open() - const row = rosterRow([{ id: 'a', label: 'read', state: 'working', startedAt: 10 }]) - await live.appendItem(row.identity, row.body, { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await live.close() - - const once = await open() - const revision = once.snapshot().items.at(-1)?.revision - await once.close() - - const twice = await open() - expect(twice.snapshot().items.at(-1)?.revision).toBe(revision) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.spec.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.spec.ts new file mode 100644 index 00000000000..b334ba3d4f2 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-positions.spec.ts @@ -0,0 +1,176 @@ +// Each submission carries where the journal wrote its row. A rejected send's own row moves to its +// rejection, so this is the only journal-order record of where it was sent. Derived on every fold: a +// replay of the same rows gives the same value, and a history page carries it. A rejection a turn's +// end made also names that turn. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { + agentJournalItemKey, + agentJournalSubmissionKey +} from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalMessageItem, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' +import { createTrackedJournalOpener } from './journal-host-database-test-support' + +export function registerJournalSubmissionPositionsTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'look around' }] + } + + let root: string | null = null + const journals = createTrackedJournalOpener() + + afterEach(async () => { + await journals.closeAll() + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } + }) + + /** A send accepted for later handover, handed over, then taken back. */ + async function sendHandedOverThenWithdrawn() { + root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + const submitted = await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: BODY, + fence: 1, + handoverRecorded: true + }) + const handedOver = await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'pending', + fence: 1, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + const pending = { ...journal.submission('send-1') } + const withdrawn = await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + fence: 1, + recovered: true + }) + return { journal, submitted, handedOver, pending, withdrawn } + } + + const TURN = { + provider: 'legacy', + agent: 'codex', + sessionId: 'session-1', + recordId: 'turn-lifecycle:turn-1' + } as const + + /** A send its turn ended without taking: the rejection names that turn. */ + async function sendWithdrawnByItsTurnEnd() { + root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) + const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) + await journal.appendSubmission({ + clientMessageId: 'send-1', + payloadFingerprint: 'send-1', + body: BODY, + fence: 1 + }) + await journal.resolveDispatch({ + clientMessageId: 'send-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + answeredInTurn: { turn: TURN, via: 'start' }, + fence: 1 + }) + return journal + } + + describe('the turn a rejected submission was answered into', () => { + it('is the turn record its rejection named, on the snapshot, a replay, and the page', async () => { + const journal = await sendWithdrawnByItsTurnEnd() + const turnItemId = agentJournalItemKey(TURN) + + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: { turnItemId, via: 'start' } + }) + expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ + expect.objectContaining({ answeredInTurn: { turnItemId, via: 'start' } }) + ]) + await journals.closeAll() + const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) + expect(replayed.submission('send-1')).toMatchObject({ + answeredInTurn: { turnItemId, via: 'start' } + }) + }) + + it('is stated as none on a take-back that names no turn, so it reads apart from an older row', async () => { + const { journal } = await sendHandedOverThenWithdrawn() + + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + answeredInTurn: null + }) + expect(readAgentSessionHydrationPage(journal).submissions[0]).toMatchObject({ + answeredInTurn: null + }) + }) + }) + + describe("a submission's journal position", () => { + it('is its own row, which a take-back does not move', async () => { + const { journal, submitted, handedOver, pending, withdrawn } = + await sendHandedOverThenWithdrawn() + + expect(pending).toMatchObject({ submittedSequence: submitted.sequence }) + expect(handedOver.sequence).toBeGreaterThan(submitted.sequence) + expect(journal.submission('send-1')).toMatchObject({ + dispatchState: 'rejected', + submittedSequence: submitted.sequence + }) + // Anti-vacuous: the send's own row moved to its rejection, so only this field says where it was sent. + expect( + journal.snapshot().items.find((item) => item.itemId === agentJournalSubmissionKey('send-1')) + ?.sequence + ).toBe(withdrawn.sequence) + }) + + it('comes back the same from a replay of the stored rows', async () => { + const { journal, submitted } = await sendHandedOverThenWithdrawn() + await journals.closeAll() + + const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) + + expect(replayed.submission('send-1')).toMatchObject({ submittedSequence: submitted.sequence }) + expect(journal).not.toBe(replayed) + }) + + it('reaches a client on the history page', async () => { + const { journal, submitted } = await sendHandedOverThenWithdrawn() + + expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ + expect.objectContaining({ + clientMessageId: 'send-1', + submittedSequence: submitted.sequence + }) + ]) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts deleted file mode 100644 index 37416378605..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-submission-positions.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -// Each submission carries where the journal wrote its row. A rejected send's own row moves to its -// rejection, so this is the only journal-order record of where it was sent. Derived on every fold: a -// replay of the same rows gives the same value, and a history page carries it. A rejection a turn's -// end made also names that turn. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { - agentJournalItemKey, - agentJournalSubmissionKey -} from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalMessageItem, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { readAgentSessionHydrationPage } from '../agent-session-wire/agent-session-history-page' -import { createTrackedJournalOpener } from './journal-host-database-test-support' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} -const BODY: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'look around' }] -} - -let root: string | null = null -const journals = createTrackedJournalOpener() - -afterEach(async () => { - await journals.closeAll() - if (root) { - await rm(root, { recursive: true, force: true }) - root = null - } -}) - -/** A send accepted for later handover, handed over, then taken back. */ -async function sendHandedOverThenWithdrawn() { - root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - const submitted = await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: 'send-1', - body: BODY, - fence: 1, - handoverRecorded: true - }) - const handedOver = await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'pending', - fence: 1, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - const pending = { ...journal.submission('send-1') } - const withdrawn = await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), - fence: 1, - recovered: true - }) - return { journal, submitted, handedOver, pending, withdrawn } -} - -const TURN = { - provider: 'legacy', - agent: 'codex', - sessionId: 'session-1', - recordId: 'turn-lifecycle:turn-1' -} as const - -/** A send its turn ended without taking: the rejection names that turn. */ -async function sendWithdrawnByItsTurnEnd() { - root = await mkdtemp(join(tmpdir(), 'orca-submission-positions-')) - const journal = await journals.open({ identity: IDENTITY, stateDirectory: root }) - await journal.appendSubmission({ - clientMessageId: 'send-1', - payloadFingerprint: 'send-1', - body: BODY, - fence: 1 - }) - await journal.resolveDispatch({ - clientMessageId: 'send-1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), - answeredInTurn: { turn: TURN, via: 'start' }, - fence: 1 - }) - return journal -} - -describe('the turn a rejected submission was answered into', () => { - it('is the turn record its rejection named, on the snapshot, a replay, and the page', async () => { - const journal = await sendWithdrawnByItsTurnEnd() - const turnItemId = agentJournalItemKey(TURN) - - expect(journal.submission('send-1')).toMatchObject({ - dispatchState: 'rejected', - answeredInTurn: { turnItemId, via: 'start' } - }) - expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ - expect.objectContaining({ answeredInTurn: { turnItemId, via: 'start' } }) - ]) - await journals.closeAll() - const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) - expect(replayed.submission('send-1')).toMatchObject({ - answeredInTurn: { turnItemId, via: 'start' } - }) - }) - - it('is stated as none on a take-back that names no turn, so it reads apart from an older row', async () => { - const { journal } = await sendHandedOverThenWithdrawn() - - expect(journal.submission('send-1')).toMatchObject({ - dispatchState: 'rejected', - answeredInTurn: null - }) - expect(readAgentSessionHydrationPage(journal).submissions[0]).toMatchObject({ - answeredInTurn: null - }) - }) -}) - -describe("a submission's journal position", () => { - it('is its own row, which a take-back does not move', async () => { - const { journal, submitted, handedOver, pending, withdrawn } = - await sendHandedOverThenWithdrawn() - - expect(pending).toMatchObject({ submittedSequence: submitted.sequence }) - expect(handedOver.sequence).toBeGreaterThan(submitted.sequence) - expect(journal.submission('send-1')).toMatchObject({ - dispatchState: 'rejected', - submittedSequence: submitted.sequence - }) - // Anti-vacuous: the send's own row moved to its rejection, so only this field says where it was sent. - expect( - journal.snapshot().items.find((item) => item.itemId === agentJournalSubmissionKey('send-1')) - ?.sequence - ).toBe(withdrawn.sequence) - }) - - it('comes back the same from a replay of the stored rows', async () => { - const { journal, submitted } = await sendHandedOverThenWithdrawn() - await journals.closeAll() - - const replayed = await journals.open({ identity: IDENTITY, stateDirectory: root! }) - - expect(replayed.submission('send-1')).toMatchObject({ submittedSequence: submitted.sequence }) - expect(journal).not.toBe(replayed) - }) - - it('reaches a client on the history page', async () => { - const { journal, submitted } = await sendHandedOverThenWithdrawn() - - expect(readAgentSessionHydrationPage(journal).submissions).toEqual([ - expect.objectContaining({ clientMessageId: 'send-1', submittedSequence: submitted.sequence }) - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.spec.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.spec.ts new file mode 100644 index 00000000000..0294759ba69 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.spec.ts @@ -0,0 +1,160 @@ +// A submission that hands off a queued draft names that draft (`queuedMessageId`), +// persisted on its journal row and published on the submission; a direct send +// names none. Clients read the link, never a draft id compared with a submission id. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-submission-schema' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { createJournalReducerState, applyJournalRow } from './journal-reducer' +import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { + agentSessionJournalProviderHandle, + claudeProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalSubmissionQueuedLinkTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] + } + + let root: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) + } + + async function handOff(journal: AgentSessionJournal, draftId: string, submissionId: string) { + await journal.queuedMessages.insert({ + messageId: draftId, + body: BODY, + fingerprint: 'fp', + hostInstance: 'p' + }) + await journal.appendSubmission( + { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, + { messageId: draftId, expect: 'waiting', settledByOp: null } + ) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-link-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('the submission names the queued draft it hands off', () => { + it('on every hand-off, and never on a direct send', async () => { + const journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + await journal.appendSubmission({ + clientMessageId: 'direct-1', + payloadFingerprint: 'fp-direct', + body: BODY, + fence: 0 + }) + expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') + expect(journal.submission('direct-1')).not.toHaveProperty('queuedMessageId') + }) + + it('survives a reload, which replays the rows through the reducer', async () => { + let journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + await journal.close() + journal = await open() + expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') + expect( + journal.snapshot().submissions.find((entry) => entry.clientMessageId === 'handoff-1') + ).toMatchObject({ queuedMessageId: 'draft-1' }) + }) + + it('refuses a caller naming a different draft than the one it consumes, and writes nothing', async () => { + const journal = await open() + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: BODY, + fingerprint: 'fp', + hostInstance: 'p' + }) + await expect( + journal.appendSubmission( + { + clientMessageId: 'handoff-1', + payloadFingerprint: 'fp', + body: BODY, + fence: 0, + queuedMessageId: 'draft-2' + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toThrow() + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('is published: the wire schema keeps the field rather than stripping it', async () => { + const journal = await open() + await handOff(journal, 'draft-1', 'handoff-1') + const published = AgentJournalSubmissionSchema.parse(journal.submission('handoff-1')) + expect(published.queuedMessageId).toBe('draft-1') + }) + }) + + describe('the persisted row', () => { + const row: JournalRow = { + v: 1, + kind: 'submission', + epoch: 'epoch-1', + seq: 1, + fence: 0, + ts: 1, + clientMessageId: 'handoff-1', + payloadFingerprint: 'fp', + providerHandle: agentSessionJournalProviderHandle(IDENTITY), + body: BODY, + queuedMessageId: 'draft-1' + } + + it('parses with the key, which a reader that does not know it simply keeps', () => { + const parsed = parseJournalRow(serializeJournalRow(row)) + expect(parsed).toMatchObject({ ok: true, row: { queuedMessageId: 'draft-1' } }) + }) + + it('keeps a row whose stored link is malformed, dropping only the link', () => { + const parsed = parseJournalRow(JSON.stringify({ ...row, queuedMessageId: 42 })) + if (!parsed.ok) { + throw new Error('the row must survive a malformed link') + } + const state = createJournalReducerState('session-q', 'epoch-1') + applyJournalRow(state, parsed.row) + expect(state.submissions.get('handoff-1')).not.toHaveProperty('queuedMessageId') + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts b/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts deleted file mode 100644 index 3857d10a4db..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-submission-queued-link.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -// A submission that hands off a queued draft names that draft (`queuedMessageId`), -// persisted on its journal row and published on the submission; a direct send -// names none. Clients read the link, never a draft id compared with a submission id. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { AgentJournalSubmissionSchema } from '../../../shared/agent-session-journal-submission-schema' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { createJournalReducerState, applyJournalRow } from './journal-reducer' -import { parseJournalRow, serializeJournalRow, type JournalRow } from './journal-row-schema' -import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { - agentSessionJournalProviderHandle, - claudeProviderHandle -} from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-q', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) -} -const BODY: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'queued text' }] -} - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function open(): Promise { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => ++clock, - mintEpoch: () => `epoch-${clock}` - }) -} - -async function handOff(journal: AgentSessionJournal, draftId: string, submissionId: string) { - await journal.queuedMessages.insert({ - messageId: draftId, - body: BODY, - fingerprint: 'fp', - hostInstance: 'p' - }) - await journal.appendSubmission( - { clientMessageId: submissionId, payloadFingerprint: 'fp', body: BODY, fence: 0 }, - { messageId: draftId, expect: 'waiting', settledByOp: null } - ) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-link-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('the submission names the queued draft it hands off', () => { - it('on every hand-off, and never on a direct send', async () => { - const journal = await open() - await handOff(journal, 'draft-1', 'handoff-1') - await journal.appendSubmission({ - clientMessageId: 'direct-1', - payloadFingerprint: 'fp-direct', - body: BODY, - fence: 0 - }) - expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') - expect(journal.submission('direct-1')).not.toHaveProperty('queuedMessageId') - }) - - it('survives a reload, which replays the rows through the reducer', async () => { - let journal = await open() - await handOff(journal, 'draft-1', 'handoff-1') - await journal.close() - journal = await open() - expect(journal.submission('handoff-1')?.queuedMessageId).toBe('draft-1') - expect( - journal.snapshot().submissions.find((entry) => entry.clientMessageId === 'handoff-1') - ).toMatchObject({ queuedMessageId: 'draft-1' }) - }) - - it('refuses a caller naming a different draft than the one it consumes, and writes nothing', async () => { - const journal = await open() - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body: BODY, - fingerprint: 'fp', - hostInstance: 'p' - }) - await expect( - journal.appendSubmission( - { - clientMessageId: 'handoff-1', - payloadFingerprint: 'fp', - body: BODY, - fence: 0, - queuedMessageId: 'draft-2' - }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } - ) - ).rejects.toThrow() - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('is published: the wire schema keeps the field rather than stripping it', async () => { - const journal = await open() - await handOff(journal, 'draft-1', 'handoff-1') - const published = AgentJournalSubmissionSchema.parse(journal.submission('handoff-1')) - expect(published.queuedMessageId).toBe('draft-1') - }) -}) - -describe('the persisted row', () => { - const row: JournalRow = { - v: 1, - kind: 'submission', - epoch: 'epoch-1', - seq: 1, - fence: 0, - ts: 1, - clientMessageId: 'handoff-1', - payloadFingerprint: 'fp', - providerHandle: agentSessionJournalProviderHandle(IDENTITY), - body: BODY, - queuedMessageId: 'draft-1' - } - - it('parses with the key, which a reader that does not know it simply keeps', () => { - const parsed = parseJournalRow(serializeJournalRow(row)) - expect(parsed).toMatchObject({ ok: true, row: { queuedMessageId: 'draft-1' } }) - }) - - it('keeps a row whose stored link is malformed, dropping only the link', () => { - const parsed = parseJournalRow(JSON.stringify({ ...row, queuedMessageId: 42 })) - if (!parsed.ok) { - throw new Error('the row must survive a malformed link') - } - const state = createJournalReducerState('session-q', 'epoch-1') - applyJournalRow(state, parsed.row) - expect(state.submissions.get('handoff-1')).not.toHaveProperty('queuedMessageId') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.spec.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.spec.ts new file mode 100644 index 00000000000..ceb85ed1f50 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.spec.ts @@ -0,0 +1,402 @@ +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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas' +import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' +import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' +import { + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { parseJournalRow, type JournalRow } from './journal-row-schema' +import * as rowTable from './journal-row-table' +import type { AgentSessionJournal } from './journal-store' + +export function registerJournalTerminalSettlementValidationTests(): void { + const journals = createTrackedJournalOpener() + let root: string + let journal: AgentSessionJournal + const identity = { provider: 'orca' as const, clientMessageId: 'old-turn' } + + async function open() { + return journals.open({ + identity: { + sessionId: 'validation', + workspaceId: 'folder', + hostId: 'host', + agent: 'codex', + providerHandle: codexProviderHandle('thread') + }, + stateDirectory: root, + now: () => 1_000 + }) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-settlement-validation-')) + journal = await open() + }) + + afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + function settle(observedAt = 1_000, lastProvenAliveAt?: number) { + return settleStaleStructuredAgentSessionState({ + journal, + sessionId: 'validation', + fence: 8, + acquisitionGeneration: 'replacement', + deathEvidence: { + kind: lastProvenAliveAt === undefined ? 'exit-observed' : 'pid-absent', + detail: '', + ownerFence: 7, + observedAt, + ...(lastProvenAliveAt === undefined ? {} : { lastProvenAliveAt }) + } + }) + } + + async function expectSendStillWritable() { + const sequence = journal.cursor().sequence + 1 + await expect( + journal.appendSubmission({ + clientMessageId: 'new-send', + payloadFingerprint: 'new', + fence: 8, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } + }) + ).resolves.toMatchObject({ sequence }) + } + + async function saveOldItems(inputs: { itemId: string; revision?: number; body: unknown }[]) { + const { db } = openTestJournalHostDatabase(root) + for (const [index, input] of inputs.entries()) { + const json = JSON.stringify({ + v: 3, + kind: 'item', + epoch: journal.epoch, + seq: index + 2, + ts: 900, + fence: 7, + revision: 1, + ...input, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + expect(parseJournalRow(json).ok).toBe(true) + insertTestJournalRowJson(db, 'validation', index + 2, json, 900) + } + await journal.close() + journal = await open() + } + + it.each([ + ['zero exit time', 0, undefined], + ['zero renewal time', 1_000, 0] + ] as const)( + 'ends an untimed turn with %s and still permits a fresh send', + async (_name, observedAt, lastProvenAliveAt) => { + await journal.appendItem( + identity, + { kind: 'turn', turnId: 'old', state: 'running' }, + { + fence: 7, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + } + ) + await settle(observedAt, lastProvenAliveAt) + expect(journal.itemBody(agentJournalItemKey(identity))).toEqual({ + kind: 'turn', + turnId: 'old', + state: 'interrupted' + }) + expect(journal.runningTurn()?.turnId ?? null).toBeNull() + const ended = journal.snapshot() + await settle(observedAt, lastProvenAliveAt) + expect(journal.snapshot()).toEqual(ended) + await expectSendStillWritable() + } + ) + + it.each([{ kind: 'tool-call' }, { providerTurnId: '' }])( + 'ends a readable legacy extension %j using a valid turn rewrite', + async (extension) => { + await saveOldItems([ + { + itemId: agentJournalItemKey(identity), + body: { + kind: 'status', + text: 'working', + turnLifecycle: { turnId: 'old', state: 'running', ...extension } + } + } + ]) + await settle() + expect(journal.itemBody(agentJournalItemKey(identity))).toEqual({ + kind: 'turn', + turnId: 'old', + state: 'interrupted', + completedAt: 1_000 + }) + expect(journal.runningTurn()?.turnId ?? null).toBeNull() + await expectSendStillWritable() + } + ) + + it.each([Number.MAX_SAFE_INTEGER + 1, 1e100])( + 'leaves exhausted saved revision %s untouched while other work commits once and a fresh send writes', + async (revision) => { + await expectUnadvanceableItemUntouched({ + itemId: agentJournalItemKey(identity), + revision, + body: { kind: 'turn', turnId: 'old', state: 'running' } + }) + await expectSendStillWritable() + } + ) + + async function expectUnadvanceableItemUntouched(saved: { + itemId: string + revision: number + body: unknown + }) { + await saveOldItems([ + saved, + { itemId: 'orca:advanceable', body: { kind: 'turn', turnId: 'other', state: 'running' } }, + { itemId: 'orca:approval', body: pendingApproval() } + ]) + const before = journal.snapshot().items.find((item) => item.itemId === saved.itemId) + const { db } = openTestJournalHostDatabase(root) + const history = liveTestJournalRows(db, 'validation') + const commits = vi.fn() + journal.observeCommits(commits) + await expect(settle()).resolves.toBeGreaterThan(0) + expect(commits).toHaveBeenCalledTimes(1) + expect(journal.snapshot().items.find((item) => item.itemId === saved.itemId)).toEqual(before) + expect(journal.itemBody('orca:advanceable')).toMatchObject({ state: 'interrupted' }) + expect(journal.itemBody('orca:approval')).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(liveTestJournalRows(db, 'validation').slice(0, history.length)).toEqual(history) + const settled = journal.snapshot() + await settle() + expect(journal.snapshot()).toEqual(settled) + expect(commits).toHaveBeenCalledTimes(1) + await journal.close() + journal = await open() + expect(journal.snapshot()).toEqual(settled) + } + + it('revises the original legacy long prompt key and still permits a fresh send', async () => { + const oldKey = `orca:${'x'.repeat(1025)}` + await saveOldItems([{ itemId: oldKey, body: pendingApproval() }]) + await settle() + expect(journal.snapshot().items).toHaveLength(1) + expect(journal.itemBody(oldKey)).toMatchObject({ resolution: { state: 'cancelled' } }) + expect(journal.snapshot().items.filter((item) => item.body.kind === 'approval')).toHaveLength(1) + await expectSendStillWritable() + }) + + function pendingApproval() { + return { + kind: 'approval', + title: 'Allow?', + detail: null, + options: [], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + } + } + + function expectRewriteTimes(value: unknown) { + if (typeof value !== 'object' || value === null) { + return + } + for (const [key, field] of Object.entries(value)) { + if (key.endsWith('At') && field !== null && field !== undefined) { + expect(typeof field).toBe('number') + expect(Number.isFinite(field)).toBe(true) + expect(field).toBeGreaterThan(0) + } + expectRewriteTimes(field) + } + } + + it.each([ + ['untimed exit', 0, undefined], + ['untimed renewal', 1_000, 0], + ['timed exit', 1_000, undefined] + ] as const)( + 'builds admitted settlement rows preserving extensions without invalid known times for %s', + async (_name, observedAt, lastProvenAliveAt) => { + const long = 'x'.repeat(1025) + const running = { turnId: 'old', state: 'running' } + const laterContextUsage = { + window: { tokens: 100, capturedAt: 1, laterWindowField: true }, + used: { kind: 'unknown', capturedAt: 1, laterUsedField: true }, + laterUsageField: { kept: true } + } + const extensionBodies = [ + { ...running, kind: 'tool-call' }, + { ...running, providerTurnId: '' }, + { ...running, providerTurnId: 42 }, + { ...running, providerTurnId: null }, + { ...running, contextUsage: { window: { tokens: -1 } } }, + { ...running, contextUsage: 'extension' }, + { + ...running, + providerTurnId: 'provider', + contextUsage: laterContextUsage + }, + { ...running, laterField: { kind: 'tool-call' } } + ] + const inputs = [ + ...extensionBodies.map((turnLifecycle, index) => ({ + itemId: `orca:extension-${index}`, + body: { kind: 'status', text: 'working', turnLifecycle } + })), + { itemId: 'orca:untimed', body: { kind: 'turn', ...running } }, + { + itemId: 'orca:timed', + body: { kind: 'turn', ...running, requestedAt: 800, startedAt: 900, completedAt: 950 } + }, + { + itemId: 'orca:last-advanceable', + revision: Number.MAX_SAFE_INTEGER - 1, + body: { kind: 'turn', ...running } + }, + { itemId: `orca:${long}`, body: pendingApproval() }, + { itemId: `claude:${long}:uuid`, body: { kind: 'turn', ...running } }, + { + itemId: `codex:thread:${long}:0`, + body: { kind: 'message', role: 'reasoning', blocks: [], state: 'running' } + }, + { + itemId: `legacy:codex:session:${long}`, + body: { kind: 'tool-call', name: 'shell', state: 'running' } + }, + { itemId: 'future-provider:opaque%ZZ', body: pendingApproval() }, + { itemId: 'orca:%78', body: pendingApproval() }, + { itemId: '', body: pendingApproval() }, + { + itemId: `orca:${long}oversized`, + body: { kind: 'tool-call', name: 'shell', input: 'x'.repeat(1_500_001), state: 'running' } + } + ] + await saveOldItems(inputs) + const { db } = openTestJournalHostDatabase(root) + const history = liveTestJournalRows(db, 'validation') + const rows: JournalRow[] = [] + const insert = rowTable.insertJournalRow + vi.spyOn(rowTable, 'insertJournalRow').mockImplementation((database, sessionId, row) => { + expect(parseJournalRow(JSON.stringify(row))).toMatchObject({ ok: true }) + const bodies = + row.kind === 'item' + ? [row.body] + : row.kind === 'lifecycle-batch' + ? row.mutations.flatMap((mutation) => + mutation.kind === 'item' ? [mutation.body] : [] + ) + : [] + for (const body of bodies) { + expect(body).toMatchObject(AgentJournalItemBodySchema.parse(body)) + expectRewriteTimes(body) + } + rows.push(row) + return insert(database, sessionId, row) + }) + await settle(observedAt, lastProvenAliveAt) + expect(rows.some((row) => row.kind === 'lifecycle-batch')).toBe(true) + expect( + rows.some((row) => row.kind === 'item' && row.itemId === `orca:${long}oversized`) + ).toBe(true) + expect(liveTestJournalRows(db, 'validation').slice(0, history.length)).toEqual(history) + for (const input of inputs) { + const body = journal.itemBody(input.itemId) + expect(body).toBeDefined() + expect(readAgentJournalTurn(body ?? undefined)?.state).not.toBe('running') + if (body?.kind === 'approval') { + expect(body.resolution.state).toBe('cancelled') + } + if (body?.kind === 'message') { + expect(body.state).toBe('completed') + } + if (body?.kind === 'tool-call') { + expect(body.state).toBe('failed') + } + } + expect(journal.itemBody('orca:extension-7')).toHaveProperty('laterField', { + kind: 'tool-call' + }) + expect(journal.itemBody('orca:extension-6')).toHaveProperty('contextUsage', laterContextUsage) + expect(journal.runningTurn()?.turnId ?? null).toBeNull() + expect(journal.snapshot().items).toHaveLength(inputs.length + 1) + const ended = journal.snapshot() + await settle(observedAt, lastProvenAliveAt) + expect(journal.snapshot()).toEqual(ended) + await journal.close() + journal = await open() + for (const input of inputs) { + expect(journal.itemBody(input.itemId)).toEqual( + ended.items.find((item) => item.itemId === input.itemId)?.body + ) + } + await expectSendStillWritable() + } + ) + + it.each([Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER - 1])( + 'advances readable saved revision %s using the existing integer admission', + async (revision) => { + await saveOldItems([ + { + itemId: agentJournalItemKey(identity), + revision, + body: { kind: 'turn', turnId: 'old', state: 'running' } + } + ]) + await settle() + expect(journal.snapshot().items[0]).toMatchObject({ + revision: Math.max(revision, 0) + 1, + body: { state: 'interrupted' } + }) + await expectSendStillWritable() + } + ) + + it('admits an answer on an exhausted approval without changing the saved item', async () => { + await saveOldItems([ + { + itemId: agentJournalItemKey(identity), + revision: Number.MAX_SAFE_INTEGER + 1, + body: pendingApproval() + } + ]) + const before = journal.snapshot().items[0] + const cursor = journal.cursor() + await expect( + journal.appendItem( + identity, + { + ...pendingApproval(), + kind: 'approval', + resolution: { + state: 'resolved', + selectedOptionId: null, + resolvedBy: 'user', + resolvedAt: 1_000 + } + }, + { fence: 8, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + ).resolves.toBeDefined() + expect(journal.cursor().sequence).toBe(cursor.sequence + 1) + expect(journal.snapshot().items[0]).toEqual(before) + await expectSendStillWritable() + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts b/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts deleted file mode 100644 index 3d9f3b0ce85..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-terminal-settlement-validation.test.ts +++ /dev/null @@ -1,396 +0,0 @@ -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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { AgentJournalItemBodySchema } from '../../../shared/agent-session-journal-schemas' -import { readAgentJournalTurn } from '../../../shared/agent-session-turn-record' -import { settleStaleStructuredAgentSessionState } from '../agent-session-wire/structured-agent-session-dead-generation-settlement' -import { - createTrackedJournalOpener, - insertTestJournalRowJson, - liveTestJournalRows, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import { parseJournalRow, type JournalRow } from './journal-row-schema' -import * as rowTable from './journal-row-table' -import type { AgentSessionJournal } from './journal-store' - -const journals = createTrackedJournalOpener() -let root: string -let journal: AgentSessionJournal -const identity = { provider: 'orca' as const, clientMessageId: 'old-turn' } - -async function open() { - return journals.open({ - identity: { - sessionId: 'validation', - workspaceId: 'folder', - hostId: 'host', - agent: 'codex', - providerHandle: codexProviderHandle('thread') - }, - stateDirectory: root, - now: () => 1_000 - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-settlement-validation-')) - journal = await open() -}) - -afterEach(async () => { - vi.restoreAllMocks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -function settle(observedAt = 1_000, lastProvenAliveAt?: number) { - return settleStaleStructuredAgentSessionState({ - journal, - sessionId: 'validation', - fence: 8, - acquisitionGeneration: 'replacement', - deathEvidence: { - kind: lastProvenAliveAt === undefined ? 'exit-observed' : 'pid-absent', - detail: '', - ownerFence: 7, - observedAt, - ...(lastProvenAliveAt === undefined ? {} : { lastProvenAliveAt }) - } - }) -} - -async function expectSendStillWritable() { - const sequence = journal.cursor().sequence + 1 - await expect( - journal.appendSubmission({ - clientMessageId: 'new-send', - payloadFingerprint: 'new', - fence: 8, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hello' }] } - }) - ).resolves.toMatchObject({ sequence }) -} - -async function saveOldItems(inputs: { itemId: string; revision?: number; body: unknown }[]) { - const { db } = openTestJournalHostDatabase(root) - for (const [index, input] of inputs.entries()) { - const json = JSON.stringify({ - v: 3, - kind: 'item', - epoch: journal.epoch, - seq: index + 2, - ts: 900, - fence: 7, - revision: 1, - ...input, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - expect(parseJournalRow(json).ok).toBe(true) - insertTestJournalRowJson(db, 'validation', index + 2, json, 900) - } - await journal.close() - journal = await open() -} - -it.each([ - ['zero exit time', 0, undefined], - ['zero renewal time', 1_000, 0] -] as const)( - 'ends an untimed turn with %s and still permits a fresh send', - async (_name, observedAt, lastProvenAliveAt) => { - await journal.appendItem( - identity, - { kind: 'turn', turnId: 'old', state: 'running' }, - { - fence: 7, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - } - ) - await settle(observedAt, lastProvenAliveAt) - expect(journal.itemBody(agentJournalItemKey(identity))).toEqual({ - kind: 'turn', - turnId: 'old', - state: 'interrupted' - }) - expect(journal.runningTurn()?.turnId ?? null).toBeNull() - const ended = journal.snapshot() - await settle(observedAt, lastProvenAliveAt) - expect(journal.snapshot()).toEqual(ended) - await expectSendStillWritable() - } -) - -it.each([{ kind: 'tool-call' }, { providerTurnId: '' }])( - 'ends a readable legacy extension %j using a valid turn rewrite', - async (extension) => { - await saveOldItems([ - { - itemId: agentJournalItemKey(identity), - body: { - kind: 'status', - text: 'working', - turnLifecycle: { turnId: 'old', state: 'running', ...extension } - } - } - ]) - await settle() - expect(journal.itemBody(agentJournalItemKey(identity))).toEqual({ - kind: 'turn', - turnId: 'old', - state: 'interrupted', - completedAt: 1_000 - }) - expect(journal.runningTurn()?.turnId ?? null).toBeNull() - await expectSendStillWritable() - } -) - -it.each([Number.MAX_SAFE_INTEGER + 1, 1e100])( - 'leaves exhausted saved revision %s untouched while other work commits once and a fresh send writes', - async (revision) => { - await expectUnadvanceableItemUntouched({ - itemId: agentJournalItemKey(identity), - revision, - body: { kind: 'turn', turnId: 'old', state: 'running' } - }) - await expectSendStillWritable() - } -) - -async function expectUnadvanceableItemUntouched(saved: { - itemId: string - revision: number - body: unknown -}) { - await saveOldItems([ - saved, - { itemId: 'orca:advanceable', body: { kind: 'turn', turnId: 'other', state: 'running' } }, - { itemId: 'orca:approval', body: pendingApproval() } - ]) - const before = journal.snapshot().items.find((item) => item.itemId === saved.itemId) - const { db } = openTestJournalHostDatabase(root) - const history = liveTestJournalRows(db, 'validation') - const commits = vi.fn() - journal.observeCommits(commits) - await expect(settle()).resolves.toBeGreaterThan(0) - expect(commits).toHaveBeenCalledTimes(1) - expect(journal.snapshot().items.find((item) => item.itemId === saved.itemId)).toEqual(before) - expect(journal.itemBody('orca:advanceable')).toMatchObject({ state: 'interrupted' }) - expect(journal.itemBody('orca:approval')).toMatchObject({ resolution: { state: 'cancelled' } }) - expect(liveTestJournalRows(db, 'validation').slice(0, history.length)).toEqual(history) - const settled = journal.snapshot() - await settle() - expect(journal.snapshot()).toEqual(settled) - expect(commits).toHaveBeenCalledTimes(1) - await journal.close() - journal = await open() - expect(journal.snapshot()).toEqual(settled) -} - -it('revises the original legacy long prompt key and still permits a fresh send', async () => { - const oldKey = `orca:${'x'.repeat(1025)}` - await saveOldItems([{ itemId: oldKey, body: pendingApproval() }]) - await settle() - expect(journal.snapshot().items).toHaveLength(1) - expect(journal.itemBody(oldKey)).toMatchObject({ resolution: { state: 'cancelled' } }) - expect(journal.snapshot().items.filter((item) => item.body.kind === 'approval')).toHaveLength(1) - await expectSendStillWritable() -}) - -function pendingApproval() { - return { - kind: 'approval', - title: 'Allow?', - detail: null, - options: [], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } - } -} - -function expectRewriteTimes(value: unknown) { - if (typeof value !== 'object' || value === null) { - return - } - for (const [key, field] of Object.entries(value)) { - if (key.endsWith('At') && field !== null && field !== undefined) { - expect(typeof field).toBe('number') - expect(Number.isFinite(field)).toBe(true) - expect(field).toBeGreaterThan(0) - } - expectRewriteTimes(field) - } -} - -it.each([ - ['untimed exit', 0, undefined], - ['untimed renewal', 1_000, 0], - ['timed exit', 1_000, undefined] -] as const)( - 'builds admitted settlement rows preserving extensions without invalid known times for %s', - async (_name, observedAt, lastProvenAliveAt) => { - const long = 'x'.repeat(1025) - const running = { turnId: 'old', state: 'running' } - const laterContextUsage = { - window: { tokens: 100, capturedAt: 1, laterWindowField: true }, - used: { kind: 'unknown', capturedAt: 1, laterUsedField: true }, - laterUsageField: { kept: true } - } - const extensionBodies = [ - { ...running, kind: 'tool-call' }, - { ...running, providerTurnId: '' }, - { ...running, providerTurnId: 42 }, - { ...running, providerTurnId: null }, - { ...running, contextUsage: { window: { tokens: -1 } } }, - { ...running, contextUsage: 'extension' }, - { - ...running, - providerTurnId: 'provider', - contextUsage: laterContextUsage - }, - { ...running, laterField: { kind: 'tool-call' } } - ] - const inputs = [ - ...extensionBodies.map((turnLifecycle, index) => ({ - itemId: `orca:extension-${index}`, - body: { kind: 'status', text: 'working', turnLifecycle } - })), - { itemId: 'orca:untimed', body: { kind: 'turn', ...running } }, - { - itemId: 'orca:timed', - body: { kind: 'turn', ...running, requestedAt: 800, startedAt: 900, completedAt: 950 } - }, - { - itemId: 'orca:last-advanceable', - revision: Number.MAX_SAFE_INTEGER - 1, - body: { kind: 'turn', ...running } - }, - { itemId: `orca:${long}`, body: pendingApproval() }, - { itemId: `claude:${long}:uuid`, body: { kind: 'turn', ...running } }, - { - itemId: `codex:thread:${long}:0`, - body: { kind: 'message', role: 'reasoning', blocks: [], state: 'running' } - }, - { - itemId: `legacy:codex:session:${long}`, - body: { kind: 'tool-call', name: 'shell', state: 'running' } - }, - { itemId: 'future-provider:opaque%ZZ', body: pendingApproval() }, - { itemId: 'orca:%78', body: pendingApproval() }, - { itemId: '', body: pendingApproval() }, - { - itemId: `orca:${long}oversized`, - body: { kind: 'tool-call', name: 'shell', input: 'x'.repeat(1_500_001), state: 'running' } - } - ] - await saveOldItems(inputs) - const { db } = openTestJournalHostDatabase(root) - const history = liveTestJournalRows(db, 'validation') - const rows: JournalRow[] = [] - const insert = rowTable.insertJournalRow - vi.spyOn(rowTable, 'insertJournalRow').mockImplementation((database, sessionId, row) => { - expect(parseJournalRow(JSON.stringify(row))).toMatchObject({ ok: true }) - const bodies = - row.kind === 'item' - ? [row.body] - : row.kind === 'lifecycle-batch' - ? row.mutations.flatMap((mutation) => (mutation.kind === 'item' ? [mutation.body] : [])) - : [] - for (const body of bodies) { - expect(body).toMatchObject(AgentJournalItemBodySchema.parse(body)) - expectRewriteTimes(body) - } - rows.push(row) - return insert(database, sessionId, row) - }) - await settle(observedAt, lastProvenAliveAt) - expect(rows.some((row) => row.kind === 'lifecycle-batch')).toBe(true) - expect(rows.some((row) => row.kind === 'item' && row.itemId === `orca:${long}oversized`)).toBe( - true - ) - expect(liveTestJournalRows(db, 'validation').slice(0, history.length)).toEqual(history) - for (const input of inputs) { - const body = journal.itemBody(input.itemId) - expect(body).toBeDefined() - expect(readAgentJournalTurn(body ?? undefined)?.state).not.toBe('running') - if (body?.kind === 'approval') { - expect(body.resolution.state).toBe('cancelled') - } - if (body?.kind === 'message') { - expect(body.state).toBe('completed') - } - if (body?.kind === 'tool-call') { - expect(body.state).toBe('failed') - } - } - expect(journal.itemBody('orca:extension-7')).toHaveProperty('laterField', { kind: 'tool-call' }) - expect(journal.itemBody('orca:extension-6')).toHaveProperty('contextUsage', laterContextUsage) - expect(journal.runningTurn()?.turnId ?? null).toBeNull() - expect(journal.snapshot().items).toHaveLength(inputs.length + 1) - const ended = journal.snapshot() - await settle(observedAt, lastProvenAliveAt) - expect(journal.snapshot()).toEqual(ended) - await journal.close() - journal = await open() - for (const input of inputs) { - expect(journal.itemBody(input.itemId)).toEqual( - ended.items.find((item) => item.itemId === input.itemId)?.body - ) - } - await expectSendStillWritable() - } -) - -it.each([Number.MAX_SAFE_INTEGER, -Number.MAX_SAFE_INTEGER - 1])( - 'advances readable saved revision %s using the existing integer admission', - async (revision) => { - await saveOldItems([ - { - itemId: agentJournalItemKey(identity), - revision, - body: { kind: 'turn', turnId: 'old', state: 'running' } - } - ]) - await settle() - expect(journal.snapshot().items[0]).toMatchObject({ - revision: Math.max(revision, 0) + 1, - body: { state: 'interrupted' } - }) - await expectSendStillWritable() - } -) - -it('admits an answer on an exhausted approval without changing the saved item', async () => { - await saveOldItems([ - { - itemId: agentJournalItemKey(identity), - revision: Number.MAX_SAFE_INTEGER + 1, - body: pendingApproval() - } - ]) - const before = journal.snapshot().items[0] - const cursor = journal.cursor() - await expect( - journal.appendItem( - identity, - { - ...pendingApproval(), - kind: 'approval', - resolution: { - state: 'resolved', - selectedOptionId: null, - resolvedBy: 'user', - resolvedAt: 1_000 - } - }, - { fence: 8, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - ).resolves.toBeDefined() - expect(journal.cursor().sequence).toBe(cursor.sequence + 1) - expect(journal.snapshot().items[0]).toEqual(before) - await expectSendStillWritable() -}) diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-content.spec.ts b/src/main/native-chat/agent-session-journal/journal-unknown-content.spec.ts new file mode 100644 index 00000000000..bda646855aa --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unknown-content.spec.ts @@ -0,0 +1,285 @@ +// Inside a row of a known kind, what a newer build wrote is never damage. A body of a kind this +// build does not know (or a plan subject of one) is kept and the chat stays writable; a mutation or +// sent message of a newer kind fails the load as a newer Orca's chat with every row kept as it was, +// even beside damage. Anything else that fails is damage: the chat fails to load, every row kept. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from './journal-host-database-test-support' + +export function registerJournalUnknownContentTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-content', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + + let root: string + const journals = createTrackedJournalOpener() + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-content-')) + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } + } + + function open() { + return journals.open({ identity: IDENTITY, stateDirectory: root }) + } + + function stored(): { seq: number; rowJson: string }[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( + (row) => ({ seq: row.seq, rowJson: row.rowJson }) + ) + } + + /** An anchor, two items, then `rows` as a newer build (or damage) wrote them, then one more item + * of this build's: closed, so the next open replays all of it. */ + async function journalWith(rows: (epoch: string) => Record[]) { + const first = await open() + await first.appendItem(item(0), { kind: 'status', text: 'one' }, SCOPE) + await first.appendItem(item(1), { kind: 'status', text: 'two' }, SCOPE) + const epoch = first.epoch + let seq = first.cursor().sequence + await journals.closeAll() + const tail = itemRow(epoch, 'codex:thread-1:turn-1:9', { kind: 'status', text: 'after' }) + const { db } = openTestJournalHostDatabase(root) + for (const row of [...rows(epoch), tail]) { + seq += 1 + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, JSON.stringify({ ...row, seq }), 5_000) + } + closeTestJournalHostDatabase(root) + return { written: stored() } + } + + function itemRow(epoch: string, itemId: string, body: Record) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'item', + itemId, + revision: 1, + body + } + } + + function batchRow(epoch: string, mutations: Record[]) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'lifecycle-batch', + settlementId: 'settle-1', + mutations + } + } + + const NEWER_BODY = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } + const PLAN_APPROVAL = { + kind: 'approval', + title: 'Approve the plan?', + detail: null, + options: [{ id: 'yes', label: 'Yes' }], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }, + subject: { kind: 'plan', text: 'Step one' } + } + + /** Refused as a newer Orca's chat on every open, with every row left as it was. */ + async function expectNewerAndKept(written: { seq: number; rowJson: string }[]) { + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(stored()).toEqual(written) + } + + /** Opens writable, keeps the newer item in the snapshot, takes a write, and leaves every earlier + * row byte-identical. */ + async function expectWritableAndKept( + written: { seq: number; rowJson: string }[], + itemId: string + ) { + const journal = await open() + expect(journal.snapshot().items.some((entry) => entry.itemId === itemId)).toBe(true) + await journal.appendItem(item(5), { kind: 'status', text: 'taken' }, SCOPE) + await journals.closeAll() + expect(stored().slice(0, written.length)).toEqual(written) + } + + describe("a newer build's body kind, kept and writable", () => { + it.each([ + ['an item body of a newer kind', NEWER_BODY], + ['a plan subject of a newer kind', { ...PLAN_APPROVAL, subject: { kind: 'diff' } }] + ])('keeps %s and stays writable', async (_name, body) => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', body) + ]) + await expectWritableAndKept(written, 'codex:thread-1:turn-1:2') + }) + + it('keeps a lifecycle mutation whose body is a newer kind, and stays writable', async () => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [ + { kind: 'item', itemId: 'codex:thread-1:turn-1:4', revision: 1, body: NEWER_BODY } + ]) + ]) + await expectWritableAndKept(written, 'codex:thread-1:turn-1:4') + }) + }) + + describe("a newer build's row content that stays a closed set", () => { + it.each([['of a newer kind', { kind: 'pin', itemId: 'codex:thread-1:turn-1:3', revision: 2 }]])( + 'fails the load on a lifecycle mutation %s', + async (_name, mutation) => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [ + { + kind: 'item', + itemId: 'codex:thread-1:turn-1:3', + revision: 1, + body: { kind: 'status', text: 'ok' } + }, + mutation + ]) + ]) + await expectNewerAndKept(written) + } + ) + + it('fails the load on a submission whose body is a newer kind', async () => { + const { written } = await journalWith((epoch) => [ + { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'submission', + clientMessageId: 'client-1', + payloadFingerprint: 'f', + providerHandle: IDENTITY.providerHandle, + body: { kind: 'voice-note', clip: 'by a newer build' } + } + ]) + await expectNewerAndKept(written) + }) + + it('fails the load on a newer mutation kind that carries no item id', async () => { + const { written } = await journalWith((epoch) => [ + batchRow(epoch, [{ kind: 'turn-settle', turnId: 'turn-1', outcome: 'done' }]) + ]) + await expectNewerAndKept(written) + }) + + it.each([ + [ + 'a row', + (epoch: string) => ({ + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + epoch, + fence: 1, + ts: 5_000, + kind: 'submission', + clientMessageId: 'client-1', + payloadFingerprint: 7, + providerHandle: IDENTITY.providerHandle, + body: { kind: 'voice-note', clip: 'by a newer build' } + }) + ], + [ + 'a batch', + (epoch: string) => + batchRow(epoch, [ + { + kind: 'item', + itemId: 'codex:thread-1:turn-1:3', + revision: 1, + body: { kind: 'diff' } + }, + { kind: 'pin' } + ]) + ] + ])( + 'fails the load as newer when damage sits beside newer content in %s', + async (_where, row) => { + const { written } = await journalWith((epoch) => [row(epoch)]) + await expectNewerAndKept(written) + } + ) + }) + + describe("a turn's context usage of a newer shape", () => { + it('is dropped like an unusable annotation: the chat stays writable and keeps the row', async () => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', { + kind: 'turn', + turnId: 'turn-1', + state: 'completed', + contextUsage: { used: { kind: 'measured', capturedAt: 1 } } + }) + ]) + const journal = await open() + expect(journal.snapshot().items[2]?.body).toEqual({ + kind: 'turn', + turnId: 'turn-1', + state: 'completed' + }) + await journals.closeAll() + expect(stored()).toEqual(written) + }) + }) + + describe('damage', () => { + it.each([ + ['a broken required field', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], + [ + 'a broken optional value', + { kind: 'turn', turnId: 'turn-1', state: 'done', durationMs: null } + ], + [ + 'a turn lifecycle whose turn is a number', + { kind: 'status', text: 'Turn started', turnLifecycle: { turnId: 7, state: 'running' } } + ], + ['an empty plan', { ...PLAN_APPROVAL, subject: { kind: 'plan', text: '' } }] + ])('fails the load from %s, and every row is kept', async (_name, body) => { + const { written } = await journalWith((epoch) => [ + itemRow(epoch, 'codex:thread-1:turn-1:2', body) + ]) + for (let attempt = 0; attempt < 2; attempt += 1) { + await expect(open()).rejects.toMatchObject({ + refusal: { + code: 'agent_session_journal_unreadable', + details: { reason: 'journalCorrupt' } + } + }) + } + expect(stored()).toEqual(written) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts deleted file mode 100644 index effd8a8136e..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-unknown-content.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -// Inside a row of a known kind, what a newer build wrote is never damage. A body of a kind this -// build does not know (or a plan subject of one) is kept and the chat stays writable; a mutation or -// sent message of a newer kind fails the load as a newer Orca's chat with every row kept as it was, -// even beside damage. Anything else that fails is damage: the chat fails to load, every row kept. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - type AgentJournalItemIdentity, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { - closeTestJournalHostDatabase, - createTrackedJournalOpener, - insertTestJournalRowJson, - liveTestJournalRows, - openTestJournalHostDatabase, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-newer-content', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} -const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - -let root: string -const journals = createTrackedJournalOpener() - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-content-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function open() { - return journals.open({ identity: IDENTITY, stateDirectory: root }) -} - -function stored(): { seq: number; rowJson: string }[] { - return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( - (row) => ({ seq: row.seq, rowJson: row.rowJson }) - ) -} - -/** An anchor, two items, then `rows` as a newer build (or damage) wrote them, then one more item - * of this build's: closed, so the next open replays all of it. */ -async function journalWith(rows: (epoch: string) => Record[]) { - const first = await open() - await first.appendItem(item(0), { kind: 'status', text: 'one' }, SCOPE) - await first.appendItem(item(1), { kind: 'status', text: 'two' }, SCOPE) - const epoch = first.epoch - let seq = first.cursor().sequence - await journals.closeAll() - const tail = itemRow(epoch, 'codex:thread-1:turn-1:9', { kind: 'status', text: 'after' }) - const { db } = openTestJournalHostDatabase(root) - for (const row of [...rows(epoch), tail]) { - seq += 1 - insertTestJournalRowJson(db, IDENTITY.sessionId, seq, JSON.stringify({ ...row, seq }), 5_000) - } - closeTestJournalHostDatabase(root) - return { written: stored() } -} - -function itemRow(epoch: string, itemId: string, body: Record) { - return { - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - epoch, - fence: 1, - ts: 5_000, - kind: 'item', - itemId, - revision: 1, - body - } -} - -function batchRow(epoch: string, mutations: Record[]) { - return { - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - epoch, - fence: 1, - ts: 5_000, - kind: 'lifecycle-batch', - settlementId: 'settle-1', - mutations - } -} - -const NEWER_BODY = { kind: 'plan-card', steps: [{ text: 'by a newer build' }] } -const PLAN_APPROVAL = { - kind: 'approval', - title: 'Approve the plan?', - detail: null, - options: [{ id: 'yes', label: 'Yes' }], - resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null }, - subject: { kind: 'plan', text: 'Step one' } -} - -/** Refused as a newer Orca's chat on every open, with every row left as it was. */ -async function expectNewerAndKept(written: { seq: number; rowJson: string }[]) { - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - expect(stored()).toEqual(written) -} - -/** Opens writable, keeps the newer item in the snapshot, takes a write, and leaves every earlier - * row byte-identical. */ -async function expectWritableAndKept(written: { seq: number; rowJson: string }[], itemId: string) { - const journal = await open() - expect(journal.snapshot().items.some((entry) => entry.itemId === itemId)).toBe(true) - await journal.appendItem(item(5), { kind: 'status', text: 'taken' }, SCOPE) - await journals.closeAll() - expect(stored().slice(0, written.length)).toEqual(written) -} - -describe("a newer build's body kind, kept and writable", () => { - it.each([ - ['an item body of a newer kind', NEWER_BODY], - ['a plan subject of a newer kind', { ...PLAN_APPROVAL, subject: { kind: 'diff' } }] - ])('keeps %s and stays writable', async (_name, body) => { - const { written } = await journalWith((epoch) => [ - itemRow(epoch, 'codex:thread-1:turn-1:2', body) - ]) - await expectWritableAndKept(written, 'codex:thread-1:turn-1:2') - }) - - it('keeps a lifecycle mutation whose body is a newer kind, and stays writable', async () => { - const { written } = await journalWith((epoch) => [ - batchRow(epoch, [ - { kind: 'item', itemId: 'codex:thread-1:turn-1:4', revision: 1, body: NEWER_BODY } - ]) - ]) - await expectWritableAndKept(written, 'codex:thread-1:turn-1:4') - }) -}) - -describe("a newer build's row content that stays a closed set", () => { - it.each([['of a newer kind', { kind: 'pin', itemId: 'codex:thread-1:turn-1:3', revision: 2 }]])( - 'fails the load on a lifecycle mutation %s', - async (_name, mutation) => { - const { written } = await journalWith((epoch) => [ - batchRow(epoch, [ - { - kind: 'item', - itemId: 'codex:thread-1:turn-1:3', - revision: 1, - body: { kind: 'status', text: 'ok' } - }, - mutation - ]) - ]) - await expectNewerAndKept(written) - } - ) - - it('fails the load on a submission whose body is a newer kind', async () => { - const { written } = await journalWith((epoch) => [ - { - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - epoch, - fence: 1, - ts: 5_000, - kind: 'submission', - clientMessageId: 'client-1', - payloadFingerprint: 'f', - providerHandle: IDENTITY.providerHandle, - body: { kind: 'voice-note', clip: 'by a newer build' } - } - ]) - await expectNewerAndKept(written) - }) - - it('fails the load on a newer mutation kind that carries no item id', async () => { - const { written } = await journalWith((epoch) => [ - batchRow(epoch, [{ kind: 'turn-settle', turnId: 'turn-1', outcome: 'done' }]) - ]) - await expectNewerAndKept(written) - }) - - it.each([ - [ - 'a row', - (epoch: string) => ({ - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - epoch, - fence: 1, - ts: 5_000, - kind: 'submission', - clientMessageId: 'client-1', - payloadFingerprint: 7, - providerHandle: IDENTITY.providerHandle, - body: { kind: 'voice-note', clip: 'by a newer build' } - }) - ], - [ - 'a batch', - (epoch: string) => - batchRow(epoch, [ - { kind: 'item', itemId: 'codex:thread-1:turn-1:3', revision: 1, body: { kind: 'diff' } }, - { kind: 'pin' } - ]) - ] - ])('fails the load as newer when damage sits beside newer content in %s', async (_where, row) => { - const { written } = await journalWith((epoch) => [row(epoch)]) - await expectNewerAndKept(written) - }) -}) - -describe("a turn's context usage of a newer shape", () => { - it('is dropped like an unusable annotation: the chat stays writable and keeps the row', async () => { - const { written } = await journalWith((epoch) => [ - itemRow(epoch, 'codex:thread-1:turn-1:2', { - kind: 'turn', - turnId: 'turn-1', - state: 'completed', - contextUsage: { used: { kind: 'measured', capturedAt: 1 } } - }) - ]) - const journal = await open() - expect(journal.snapshot().items[2]?.body).toEqual({ - kind: 'turn', - turnId: 'turn-1', - state: 'completed' - }) - await journals.closeAll() - expect(stored()).toEqual(written) - }) -}) - -describe('damage', () => { - it.each([ - ['a broken required field', { kind: 'diff', path: 'a.ts', patch: { head: 'x' } }], - [ - 'a broken optional value', - { kind: 'turn', turnId: 'turn-1', state: 'done', durationMs: null } - ], - [ - 'a turn lifecycle whose turn is a number', - { kind: 'status', text: 'Turn started', turnLifecycle: { turnId: 7, state: 'running' } } - ], - ['an empty plan', { ...PLAN_APPROVAL, subject: { kind: 'plan', text: '' } }] - ])('fails the load from %s, and every row is kept', async (_name, body) => { - const { written } = await journalWith((epoch) => [ - itemRow(epoch, 'codex:thread-1:turn-1:2', body) - ]) - for (let attempt = 0; attempt < 2; attempt += 1) { - await expect(open()).rejects.toMatchObject({ - refusal: { - code: 'agent_session_journal_unreadable', - details: { reason: 'journalCorrupt' } - } - }) - } - expect(stored()).toEqual(written) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.spec.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.spec.ts new file mode 100644 index 00000000000..4300d0b574a --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.spec.ts @@ -0,0 +1,276 @@ +// A newer build's row kind is never damage: like a newer row version, it fails this build's load as +// a newer Orca's chat with every row kept, so the newer build still reads the whole chat after an +// upgrade. +// A row whose stored sequence and body disagree is damage, which fails the load with every row kept. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + AGENT_JOURNAL_THREAD_SCOPE, + AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + type AgentJournalItemIdentity, + type AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabase, + createTrackedJournalOpener, + insertTestJournalRowJson, + liveTestJournalRows, + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from './journal-host-database-test-support' +import { parseJournalRow, type JournalRow } from './journal-row-schema' +import { + agentSessionJournalProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalUnknownRowKindTests(): void { + const UNLOADABLE = { + refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } + } + + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-newer-kind', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } + const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + + let root: string + const journals = createTrackedJournalOpener() + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-kind-')) + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + function item(ordinal: number): AgentJournalItemIdentity { + return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } + } + + function itemIds(journal: { snapshot: () => { items: { itemId: string }[] } }): string[] { + return journal.snapshot().items.map((entry) => entry.itemId) + } + + /** What a newer build would write: a kind this build does not know, in the envelope every row keeps. */ + function newerRow(epoch: string, seq: number, extra: Record = {}) { + return { + v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, + kind: 'future-mark', + epoch, + seq, + fence: 1, + ts: 5_000 + seq, + payload: { said: 'by a newer build', seq }, + ...extra + } + } + + function open() { + return journals.open({ identity: IDENTITY, stateDirectory: root }) + } + + /** Closes the chat, applies `edit` to its stored rows as a newer build or a bad write would, and + * reopens it as a restarted host would. */ + /** Rewrites rows with every journal closed, so the caller's next open replays them. */ + async function rewriteClosed( + edit: (put: (seq: number, json: string) => void) => void = () => {} + ) { + await journals.closeAll() + const { db } = openTestJournalHostDatabase(root) + edit((seq, json) => { + db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( + IDENTITY.sessionId, + seq + ) + insertTestJournalRowJson(db, IDENTITY.sessionId, seq, json, 5_000 + seq) + }) + closeTestJournalHostDatabase(root) + } + + async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { + await rewriteClosed(edit) + return open() + } + + function stored(): { seq: number; rowJson: string }[] { + return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( + (row) => ({ seq: row.seq, rowJson: row.rowJson }) + ) + } + + function storedKinds(): [number, unknown][] { + return stored().map((row) => [row.seq, JSON.parse(row.rowJson).kind]) + } + + /** A journal of its anchor and one item, closed: the next row lands at sequence 3. */ + async function journalWithOneItem(): Promise { + const first = await open() + await first.appendItem(item(0), { kind: 'status', text: 'before' }, SCOPE) + return first.epoch + } + + /** One valid row of every kind this build writes; a kind added to the union without one here + * fails to compile. */ + function rowOfEveryKind(): { [Kind in JournalRow['kind']]: Extract } { + const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1 } + const providerHandle = agentSessionJournalProviderHandle(IDENTITY) + return { + epoch: { ...base, seq: 1, kind: 'epoch', reason: 'session_created', providerHandle }, + item: { + ...base, + seq: 2, + kind: 'item', + itemId: 'i', + revision: 1, + body: { kind: 'status', text: 'x' } + }, + tombstone: { ...base, seq: 3, kind: 'tombstone', itemId: 'i', revision: 2 }, + submission: { + ...base, + seq: 4, + kind: 'submission', + clientMessageId: 'c', + payloadFingerprint: 'f', + providerHandle, + body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } + }, + dispatch: { + ...base, + seq: 5, + kind: 'dispatch', + clientMessageId: 'c', + state: 'accepted', + providerItemId: null, + reason: null + }, + 'lifecycle-batch': { + ...base, + seq: 6, + kind: 'lifecycle-batch', + settlementId: 's', + mutations: [{ kind: 'tombstone', itemId: 'i', revision: 3 }] + } + } + } + + it("reads a row of every kind this build writes, never as a newer build's kind", () => { + for (const row of Object.values(rowOfEveryKind())) { + expect(parseJournalRow(JSON.stringify(row))).toEqual({ ok: true, row }) + } + }) + + describe('parsing a row of a kind this build does not know', () => { + it('reads one in the envelope every row keeps as unreadable', () => { + expect(parseJournalRow(JSON.stringify(newerRow('epoch-1', 7)))).toEqual({ + ok: false, + unreadable: true + }) + }) + + it.each([ + ['no sequence', { seq: undefined }], + ['a sequence of 0', { seq: 0 }], + ['a string sequence', { seq: '7' }], + ['a fractional fence', { fence: 1.5 }], + ['a string timestamp', { ts: '5007' }], + ['an empty epoch', { epoch: '' }], + ['an empty kind', { kind: '' }], + ['a kind of only whitespace', { kind: ' \t' }], + ['a kind that is not a string', { kind: 7 }] + ])('reads one with %s as malformed', (_name, broken) => { + const parsed = parseJournalRow(JSON.stringify(newerRow('epoch-1', 7, broken))) + expect(parsed).toEqual({ ok: false, unreadable: false }) + }) + + it('reads a known kind that fails its own checks as malformed', () => { + const parsed = parseJournalRow( + JSON.stringify({ ...newerRow('epoch-1', 7), kind: 'item', itemId: 'x', revision: 1 }) + ) + expect(parsed).toEqual({ ok: false, unreadable: false }) + }) + + it('reads a future schema version as unreadable before it looks at the kind', () => { + const parsed = parseJournalRow( + JSON.stringify(newerRow('epoch-1', 7, { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })) + ) + expect(parsed).toEqual({ ok: false, unreadable: true }) + }) + }) + + describe("a journal holding a newer build's row kind", () => { + it("fails the load as a newer Orca's chat, keeps every row, and reads whole once the kind is known", async () => { + const first = await open() + for (const ordinal of [0, 1, 2, 3]) { + await first.appendItem(item(ordinal), { kind: 'status', text: `item ${ordinal}` }, SCOPE) + } + const epoch = first.epoch + await journals.closeAll() + const known = stored().find((row) => row.seq === 3)?.rowJson ?? '' + const newer = JSON.stringify(newerRow(epoch, 3)) + + await rewriteClosed((put) => put(3, newer)) + const refused = stored() + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(stored()).toEqual(refused) + expect(stored().find((row) => row.seq === 3)?.rowJson).toBe(newer) + + // Stand-in for the newer build after an upgrade: the same place holds a kind it reads. + const upgraded = await restartAfter((put) => put(3, known)) + expect(itemIds(upgraded)).toHaveLength(4) + await upgraded.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) + expect(upgraded.cursor().sequence).toBe(6) + }) + + it("fails the load as a newer Orca's chat with nothing deleted when the row names another sequence than its key", async () => { + const epoch = await journalWithOneItem() + await rewriteClosed((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) + await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'future-mark'] + ]) + }) + + it('reads a newer kind whose envelope is broken as damage, and keeps it', async () => { + const epoch = await journalWithOneItem() + await expect( + restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' })))) + ).rejects.toMatchObject(UNLOADABLE) + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'future-mark'] + ]) + }) + }) + + describe('a row whose body names another sequence than its stored key', () => { + it.each([ + ['an earlier', 2], + ['a later', 9] + ])('fails the load and is kept when it names %s one', async (_name, seq) => { + const epoch = await journalWithOneItem() + const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } + await expect(restartAfter((put) => put(3, JSON.stringify(tombstone)))).rejects.toMatchObject( + UNLOADABLE + ) + expect(storedKinds()).toEqual([ + [1, 'epoch'], + [2, 'item'], + [3, 'tombstone'] + ]) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts b/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts deleted file mode 100644 index 9aecd47da97..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-unknown-row-kind.test.ts +++ /dev/null @@ -1,272 +0,0 @@ -// A newer build's row kind is never damage: like a newer row version, it fails this build's load as -// a newer Orca's chat with every row kept, so the newer build still reads the whole chat after an -// upgrade. -// A row whose stored sequence and body disagree is damage, which fails the load with every row kept. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { - AGENT_JOURNAL_THREAD_SCOPE, - AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - type AgentJournalItemIdentity, - type AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { - closeTestJournalHostDatabase, - createTrackedJournalOpener, - insertTestJournalRowJson, - liveTestJournalRows, - openTestJournalHostDatabase, - SAVED_BY_NEWER_ORCA -} from './journal-host-database-test-support' -import { parseJournalRow, type JournalRow } from './journal-row-schema' -import { - agentSessionJournalProviderHandle, - codexProviderHandle -} from '../../../shared/agent-session-provider-handle-encoding' - -const UNLOADABLE = { - refusal: { code: 'agent_session_journal_unreadable', details: { reason: 'journalCorrupt' } } -} - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-newer-kind', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} -const SCOPE = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - -let root: string -const journals = createTrackedJournalOpener() - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-newer-kind-')) -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -function item(ordinal: number): AgentJournalItemIdentity { - return { provider: 'codex', threadId: 'thread-1', turnId: 'turn-1', ordinal } -} - -function itemIds(journal: { snapshot: () => { items: { itemId: string }[] } }): string[] { - return journal.snapshot().items.map((entry) => entry.itemId) -} - -/** What a newer build would write: a kind this build does not know, in the envelope every row keeps. */ -function newerRow(epoch: string, seq: number, extra: Record = {}) { - return { - v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, - kind: 'future-mark', - epoch, - seq, - fence: 1, - ts: 5_000 + seq, - payload: { said: 'by a newer build', seq }, - ...extra - } -} - -function open() { - return journals.open({ identity: IDENTITY, stateDirectory: root }) -} - -/** Closes the chat, applies `edit` to its stored rows as a newer build or a bad write would, and - * reopens it as a restarted host would. */ -/** Rewrites rows with every journal closed, so the caller's next open replays them. */ -async function rewriteClosed(edit: (put: (seq: number, json: string) => void) => void = () => {}) { - await journals.closeAll() - const { db } = openTestJournalHostDatabase(root) - edit((seq, json) => { - db.prepare('DELETE FROM journal_rows WHERE session_id = ? AND seq = ?').run( - IDENTITY.sessionId, - seq - ) - insertTestJournalRowJson(db, IDENTITY.sessionId, seq, json, 5_000 + seq) - }) - closeTestJournalHostDatabase(root) -} - -async function restartAfter(edit: (put: (seq: number, json: string) => void) => void = () => {}) { - await rewriteClosed(edit) - return open() -} - -function stored(): { seq: number; rowJson: string }[] { - return liveTestJournalRows(openTestJournalHostDatabase(root).db, IDENTITY.sessionId).map( - (row) => ({ seq: row.seq, rowJson: row.rowJson }) - ) -} - -function storedKinds(): [number, unknown][] { - return stored().map((row) => [row.seq, JSON.parse(row.rowJson).kind]) -} - -/** A journal of its anchor and one item, closed: the next row lands at sequence 3. */ -async function journalWithOneItem(): Promise { - const first = await open() - await first.appendItem(item(0), { kind: 'status', text: 'before' }, SCOPE) - return first.epoch -} - -/** One valid row of every kind this build writes; a kind added to the union without one here - * fails to compile. */ -function rowOfEveryKind(): { [Kind in JournalRow['kind']]: Extract } { - const base = { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION, epoch: 'epoch-1', fence: 1, ts: 1 } - const providerHandle = agentSessionJournalProviderHandle(IDENTITY) - return { - epoch: { ...base, seq: 1, kind: 'epoch', reason: 'session_created', providerHandle }, - item: { - ...base, - seq: 2, - kind: 'item', - itemId: 'i', - revision: 1, - body: { kind: 'status', text: 'x' } - }, - tombstone: { ...base, seq: 3, kind: 'tombstone', itemId: 'i', revision: 2 }, - submission: { - ...base, - seq: 4, - kind: 'submission', - clientMessageId: 'c', - payloadFingerprint: 'f', - providerHandle, - body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] } - }, - dispatch: { - ...base, - seq: 5, - kind: 'dispatch', - clientMessageId: 'c', - state: 'accepted', - providerItemId: null, - reason: null - }, - 'lifecycle-batch': { - ...base, - seq: 6, - kind: 'lifecycle-batch', - settlementId: 's', - mutations: [{ kind: 'tombstone', itemId: 'i', revision: 3 }] - } - } -} - -it("reads a row of every kind this build writes, never as a newer build's kind", () => { - for (const row of Object.values(rowOfEveryKind())) { - expect(parseJournalRow(JSON.stringify(row))).toEqual({ ok: true, row }) - } -}) - -describe('parsing a row of a kind this build does not know', () => { - it('reads one in the envelope every row keeps as unreadable', () => { - expect(parseJournalRow(JSON.stringify(newerRow('epoch-1', 7)))).toEqual({ - ok: false, - unreadable: true - }) - }) - - it.each([ - ['no sequence', { seq: undefined }], - ['a sequence of 0', { seq: 0 }], - ['a string sequence', { seq: '7' }], - ['a fractional fence', { fence: 1.5 }], - ['a string timestamp', { ts: '5007' }], - ['an empty epoch', { epoch: '' }], - ['an empty kind', { kind: '' }], - ['a kind of only whitespace', { kind: ' \t' }], - ['a kind that is not a string', { kind: 7 }] - ])('reads one with %s as malformed', (_name, broken) => { - const parsed = parseJournalRow(JSON.stringify(newerRow('epoch-1', 7, broken))) - expect(parsed).toEqual({ ok: false, unreadable: false }) - }) - - it('reads a known kind that fails its own checks as malformed', () => { - const parsed = parseJournalRow( - JSON.stringify({ ...newerRow('epoch-1', 7), kind: 'item', itemId: 'x', revision: 1 }) - ) - expect(parsed).toEqual({ ok: false, unreadable: false }) - }) - - it('reads a future schema version as unreadable before it looks at the kind', () => { - const parsed = parseJournalRow( - JSON.stringify(newerRow('epoch-1', 7, { v: AGENT_SESSION_JOURNAL_SCHEMA_VERSION + 1 })) - ) - expect(parsed).toEqual({ ok: false, unreadable: true }) - }) -}) - -describe("a journal holding a newer build's row kind", () => { - it("fails the load as a newer Orca's chat, keeps every row, and reads whole once the kind is known", async () => { - const first = await open() - for (const ordinal of [0, 1, 2, 3]) { - await first.appendItem(item(ordinal), { kind: 'status', text: `item ${ordinal}` }, SCOPE) - } - const epoch = first.epoch - await journals.closeAll() - const known = stored().find((row) => row.seq === 3)?.rowJson ?? '' - const newer = JSON.stringify(newerRow(epoch, 3)) - - await rewriteClosed((put) => put(3, newer)) - const refused = stored() - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - expect(stored()).toEqual(refused) - expect(stored().find((row) => row.seq === 3)?.rowJson).toBe(newer) - - // Stand-in for the newer build after an upgrade: the same place holds a kind it reads. - const upgraded = await restartAfter((put) => put(3, known)) - expect(itemIds(upgraded)).toHaveLength(4) - await upgraded.appendItem(item(4), { kind: 'status', text: 'after' }, SCOPE) - expect(upgraded.cursor().sequence).toBe(6) - }) - - it("fails the load as a newer Orca's chat with nothing deleted when the row names another sequence than its key", async () => { - const epoch = await journalWithOneItem() - await rewriteClosed((put) => put(3, JSON.stringify(newerRow(epoch, 9)))) - await expect(open()).rejects.toMatchObject(SAVED_BY_NEWER_ORCA) - expect(storedKinds()).toEqual([ - [1, 'epoch'], - [2, 'item'], - [3, 'future-mark'] - ]) - }) - - it('reads a newer kind whose envelope is broken as damage, and keeps it', async () => { - const epoch = await journalWithOneItem() - await expect( - restartAfter((put) => put(3, JSON.stringify(newerRow(epoch, 3, { fence: 'one' })))) - ).rejects.toMatchObject(UNLOADABLE) - expect(storedKinds()).toEqual([ - [1, 'epoch'], - [2, 'item'], - [3, 'future-mark'] - ]) - }) -}) - -describe('a row whose body names another sequence than its stored key', () => { - it.each([ - ['an earlier', 2], - ['a later', 9] - ])('fails the load and is kept when it names %s one', async (_name, seq) => { - const epoch = await journalWithOneItem() - const tombstone = { ...newerRow(epoch, seq), kind: 'tombstone', itemId: 'gone', revision: 1 } - await expect(restartAfter((put) => put(3, JSON.stringify(tombstone)))).rejects.toMatchObject( - UNLOADABLE - ) - expect(storedKinds()).toEqual([ - [1, 'epoch'], - [2, 'item'], - [3, 'tombstone'] - ]) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.spec.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.spec.ts new file mode 100644 index 00000000000..3b169d670bb --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.spec.ts @@ -0,0 +1,508 @@ +// Which unsent sends a restart or a close keeps as held cards, where they go in the queue, and that +// a send's source is recorded and folded. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import type { + AgentJournalMessageItem, + AgentJournalSubmission, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { + USER_MESSAGE_SOURCE, + type AgentMessageSource, + type AgentSessionMessageSource +} from '../../../shared/agent-session-message-source' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' +import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' +import { + holdUnsentSends, + unsentSendKeptAsCard, + type UnsentSendHold +} from './journal-unsent-send-hold' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener, + liveTestJournalRows, + openTestJournalHostDatabase, + updateTestJournalRowJson +} from './journal-host-database-test-support' + +export function registerJournalUnsentSendHoldTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-held', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) + const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { + surface: 'rejection' + }) + /** The live host process the cards are written for. */ + const HOST = 'host-instance-2' + + let root: string + let clock = 1_000 + let epochs = 0 + const journals = createTrackedJournalOpener() + + function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } + + function fingerprint(body: AgentJournalMessageItem): string { + return structuredAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: IDENTITY.sessionId, + fields: { body } + }) + } + + async function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => (clock += 1), + mintEpoch: () => `epoch-${(epochs += 1)}` + }) + } + + function hold(journal: AgentSessionJournal, settle: UnsentSendHold = { cause: 'hostRestarted' }) { + return holdUnsentSends(journal, { fence: 0, hostInstance: HOST, hold: settle }) + } + + /** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ + const MAIL_SOURCE: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null }, + name: null + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'agent:worker', + dispatchId: null, + messages: [] + } + } + + async function accept( + journal: AgentSessionJournal, + id: string, + fields: { + body?: AgentJournalMessageItem + origin?: 'client' | 'host' + source?: AgentSessionMessageSource + } = {} + ): Promise { + const body = fields.body ?? message(`text of ${id}`) + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: fingerprint(body), + body, + fence: 0, + handoverRecorded: true, + ...(fields.origin ? { origin: fields.origin } : {}), + ...(fields.source ? { source: fields.source } : {}) + }) + } + + /** Writes rows as a process that then quit, and opens the journal again as the next one. */ + async function afterRestart( + write: (journal: AgentSessionJournal) => Promise + ): Promise { + const earlier = await open() + await write(earlier) + await earlier.close() + return open() + } + + /** Rewrites a stored submission's `source` as another build would have written it. */ + function storeSubmissionSourceAs( + journal: AgentSessionJournal, + id: string, + source: unknown + ): void { + const seq = journal.submission(id)?.submittedSequence + const { db } = openTestJournalHostDatabase(root) + const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === seq) + if (!stored) { + throw new Error(`no stored row for ${id}`) + } + const row: unknown = JSON.parse(stored.rowJson) + updateTestJournalRowJson( + db, + IDENTITY.sessionId, + stored.seq, + JSON.stringify({ ...(typeof row === 'object' ? row : {}), source }) + ) + } + + function cardOrder(journal: AgentSessionJournal): string[] { + return journal.queuedMessages + .list() + .filter((card) => card.state === 'waiting') + .map((card) => card.messageId) + } + + beforeEach(async () => { + epochs = 0 + root = await mkdtemp(join(tmpdir(), 'orca-unsent-hold-')) + }) + + afterEach(async () => { + await journals.closeAll() + await closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) + }) + + describe('which sends an earlier host process left unsent are kept', () => { + it('keeps a person’s and a launch’s text, and an older build’s client send', async () => { + const journal = await afterRestart(async (earlier) => { + await accept(earlier, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'launch', { origin: 'host', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'legacy-client', { origin: 'client' }) + }) + await hold(journal) + + expect(cardOrder(journal)).toEqual(['person', 'launch', 'legacy-client']) + await journal.close() + // The rejection names the card it was kept as, read back from disk. + const reopened = await open() + for (const id of ['person', 'launch', 'legacy-client']) { + expect(reopened.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED, + keptAsQueuedMessageId: id + }) + expect(reopened.queuedMessages.get(id)).toMatchObject({ + state: 'waiting', + holdReason: null, + hostInstance: HOST, + body: message(`text of ${id}`), + fingerprint: fingerprint(message(`text of ${id}`)), + carriedFrom: null, + queuedAt: { epoch: 'epoch-1', sequence: reopened.submission(id)?.acceptedSequence } + }) + } + }) + + it('rejects mail, a dispatch preamble, a continuation, /compact, an image, and a send no one can attribute', async () => { + const image: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [ + { type: 'text', text: 'look' }, + { type: 'image-ref', path: '/tmp/attachment.png' } + ] + } + const journal = await afterRestart(async (earlier) => { + await accept(earlier, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await accept(earlier, 'dispatch', { origin: 'host' }) + await accept(earlier, 'continuation', { origin: 'host' }) + await accept(earlier, 'compact', { + origin: 'client', + source: USER_MESSAGE_SOURCE, + body: structuredAgentSessionCompactBody() + }) + await accept(earlier, 'image', { + origin: 'client', + source: USER_MESSAGE_SOURCE, + body: image + }) + await accept(earlier, 'legacy-host', { origin: 'host' }) + await accept(earlier, 'no-origin') + }) + await hold(journal) + + expect(journal.queuedMessages.list()).toEqual([]) + for (const id of [ + 'mail', + 'dispatch', + 'continuation', + 'compact', + 'image', + 'legacy-host', + 'no-origin' + ]) { + expect(journal.submission(id)).toMatchObject({ + dispatchState: 'rejected', + ...HOST_RESTARTED + }) + expect(journal.submission(id)).not.toHaveProperty('keptAsQueuedMessageId') + } + }) + + // Whoever sent it, the card comes back with no hold of its own, to wait under the reopen's pause. + it.each([ + { by: 'Send now', origin: 'client' as const }, + { by: 'the queue', origin: 'host' as const } + ])( + 'a card’s own hand-off by $by returns its card, and makes no second one', + async ({ origin }) => { + const journal = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'card', + body: message('card text'), + fingerprint: fingerprint(message('card text')), + hostInstance: 'proc-1' + }) + await earlier.appendSubmission( + { + clientMessageId: 'handoff', + payloadFingerprint: fingerprint(message('card text')), + body: message('card text'), + fence: 0, + handoverRecorded: true, + origin + }, + { messageId: 'card', expect: 'waiting', settledByOp: null } + ) + }) + await hold(journal) + + expect(journal.queuedMessages.list()).toHaveLength(1) + expect(journal.queuedMessages.get('card')).toMatchObject({ + state: 'waiting', + consumedAs: null, + holdReason: null + }) + expect(journal.queuedMessages.get('handoff')).toBeNull() + } + ) + + it('leaves alone what this process accepted', async () => { + const journal = await open() + await accept(journal, 'mine', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await hold(journal) + expect(journal.submission('mine')?.dispatchState).toBe('pending') + expect(journal.queuedMessages.list()).toEqual([]) + }) + + it('never keeps a queue hand-off, a card’s link, or a send with no message body', () => { + const body = message('x') + expect(unsentSendKeptAsCard({ source: USER_MESSAGE_SOURCE }, body)).toBe(body) + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: 'agent' } }, body)).toBeNull() + // Undecodable (folded as an empty kind) and unknown kinds are never a person's. + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: '' } }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: 'orca' } }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client', queuedMessageId: 'card' }, body)).toBeNull() + expect(unsentSendKeptAsCard({ origin: 'client' }, null)).toBeNull() + }) + + // Only a row with no source at all is an older build's; a newer build's kind may name a sender + // that must not become a card. + it('never keeps a source kind this build does not know, whatever its origin', async () => { + const journal = await afterRestart(async (earlier) => { + await earlier.appendSubmission({ + clientMessageId: 'future', + payloadFingerprint: fingerprint(message('from a newer build')), + body: message('from a newer build'), + fence: 0, + handoverRecorded: true, + origin: 'client', + source: USER_MESSAGE_SOURCE + }) + storeSubmissionSourceAs(earlier, 'future', { kind: 'a-newer-kind' }) + }) + expect(journal.submission('future')?.source).toEqual({ kind: 'a-newer-kind' }) + await hold(journal) + expect(journal.queuedMessages.list()).toEqual([]) + expect(journal.submission('future')).toMatchObject({ dispatchState: 'rejected' }) + }) + + // A source with no readable kind is not a row without one: an older build's rule never applies. + it('never keeps a send whose stored source has no readable kind', async () => { + const journal = await afterRestart(async (earlier) => { + await earlier.appendSubmission({ + clientMessageId: 'unreadable', + payloadFingerprint: fingerprint(message('unreadable')), + body: message('unreadable'), + fence: 0, + handoverRecorded: true, + origin: 'client', + source: USER_MESSAGE_SOURCE + }) + storeSubmissionSourceAs(earlier, 'unreadable', {}) + }) + expect(journal.submission('unreadable')?.source).toEqual({ kind: '' }) + await hold(journal) + expect(journal.queuedMessages.list()).toEqual([]) + expect(journal.submission('unreadable')).toMatchObject({ dispatchState: 'rejected' }) + }) + }) + + describe('where kept sends go in the queue', () => { + // A Send the person asked for comes back kept among them; the queue's own hand-off stays put. + it.each([ + { by: 'Send now', origin: 'client' as const, order: ['A', 'H', 'B', 'C'] }, + { by: 'the queue', origin: 'host' as const, order: ['A', 'B', 'H', 'C'] } + ])( + 'in acceptance order, ahead of the cards already waiting, a hand-off by $by among them', + async ({ origin, order }) => { + const journal = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'H', + body: message('H'), + fingerprint: fingerprint(message('H')), + hostInstance: 'proc-1' + }) + await earlier.queuedMessages.insert({ + messageId: 'C', + body: message('C'), + fingerprint: fingerprint(message('C')), + hostInstance: 'proc-1' + }) + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await earlier.appendSubmission( + { + clientMessageId: 'H-handoff', + payloadFingerprint: fingerprint(message('H')), + body: message('H'), + fence: 0, + handoverRecorded: true, + origin + }, + { messageId: 'H', expect: 'waiting', settledByOp: null } + ) + await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + await hold(journal) + + expect(cardOrder(journal)).toEqual(order) + } + ) + + it('a run a crash cut short is placed again with the rest, in acceptance order', async () => { + const interrupted = await afterRestart(async (earlier) => { + await earlier.queuedMessages.insert({ + messageId: 'C', + body: message('C'), + fingerprint: fingerprint(message('C')), + hostInstance: 'proc-1' + }) + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + // That run kept A, at a stale place behind C, then died before B. + await interrupted.resolveDispatch( + { + clientMessageId: 'A', + state: 'rejected', + ...HOST_RESTARTED, + fence: 0, + recovered: true, + keptAsQueuedMessageId: 'A' + }, + (db) => { + interrupted.queuedMessages.holdInTransaction(db, { + card: { + messageId: 'A', + body: message('text of A'), + fingerprint: fingerprint(message('text of A')), + hostInstance: HOST, + queuedAt: { + epoch: 'epoch-1', + sequence: interrupted.submission('A')!.acceptedSequence! + }, + position: 5 + }, + positions: [] + }) + } + ) + await interrupted.close() + const journal = await open() + await hold(journal) + + expect(cardOrder(journal)).toEqual(['A', 'B', 'C']) + }) + }) + + describe('a close of the chat', () => { + it('keeps a person’s unsent send in place, rejected as closed, by the same rule as a restart', async () => { + const journal = await open() + await accept(journal, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await hold(journal, { cause: 'chatClosed' }) + + expect(cardOrder(journal)).toEqual(['person']) + expect(journal.queuedMessages.get('person')).toMatchObject({ + holdReason: null, + hostInstance: HOST + }) + for (const id of ['person', 'mail']) { + expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) + } + }) + + it('settles only what `which` names, leaving a later send queued', async () => { + const journal = await open() + await accept(journal, 'before', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'after', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await hold(journal, { + cause: 'chatClosed', + which: (submission) => submission.clientMessageId === 'before' + }) + + expect(cardOrder(journal)).toEqual(['before']) + expect(journal.submission('after')?.dispatchState).toBe('pending') + }) + }) + + describe('the order kept cards stand in', () => { + // Sequences restart with each epoch, so a card kept before a rewind has no sequence to compare. + it('a card kept before the epoch rolled stays ahead of one kept after it', async () => { + const journal = await afterRestart(async (earlier) => { + for (const id of ['m1', 'm2', 'm3']) { + await accept(earlier, id, { origin: 'host', source: MAIL_SOURCE }) + } + await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) + }) + await hold(journal) + await journal.rollEpoch('handle_forked', 0) + await accept(journal, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) + await journal.close() + const reopened = await open() + await hold(reopened) + + expect(reopened.queuedMessages.get('A')?.queuedAt?.epoch).not.toBe( + reopened.queuedMessages.get('B')?.queuedAt?.epoch + ) + expect(cardOrder(reopened)).toEqual(['A', 'B']) + }) + }) + + describe('the source a send records', () => { + it('is folded from the row; a row without one folds without it', async () => { + const journal = await open() + await accept(journal, 'with-source', { origin: 'host', source: USER_MESSAGE_SOURCE }) + await accept(journal, 'without', { origin: 'client' }) + const folded: AgentJournalSubmission | undefined = journal.submission('with-source') + expect(folded?.source).toEqual({ kind: 'user' }) + expect(journal.submission('without')).not.toHaveProperty('source') + }) + + // Who sent it stays on the card, host-only; a submission is published to clients as it folds. + it('keeps only the kind of an agent’s source, never its senders', async () => { + const journal = await open() + await accept(journal, 'mail', { origin: 'host', source: MAIL_SOURCE }) + await journal.close() + const reopened = await open() + expect(reopened.submission('mail')?.source).toEqual({ kind: 'agent' }) + expect(JSON.stringify(reopened.submission('mail'))).not.toContain('agent:coordinator') + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts b/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts deleted file mode 100644 index 29fb7eb35fa..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-unsent-send-hold.test.ts +++ /dev/null @@ -1,490 +0,0 @@ -// Which unsent sends a restart or a close keeps as held cards, where they go in the queue, and that -// a send's source is recorded and folded. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import type { - AgentJournalMessageItem, - AgentJournalSubmission, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { - USER_MESSAGE_SOURCE, - type AgentMessageSource, - type AgentSessionMessageSource -} from '../../../shared/agent-session-message-source' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' -import { structuredAgentSessionPayloadFingerprint } from '../../../shared/structured-agent-session-mutation' -import { structuredAgentSessionCompactBody } from '../agent-session-wire/structured-agent-session-command-turn' -import { - holdUnsentSends, - unsentSendKeptAsCard, - type UnsentSendHold -} from './journal-unsent-send-hold' -import type { AgentSessionJournal } from './journal-store' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener, - liveTestJournalRows, - openTestJournalHostDatabase, - updateTestJournalRowJson -} from './journal-host-database-test-support' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-held', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) -} -const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { - surface: 'rejection' -}) -const CHAT_CLOSED = agentSessionFailureWords(agentSessionFailureFact('chatClosed'), { - surface: 'rejection' -}) -/** The live host process the cards are written for. */ -const HOST = 'host-instance-2' - -let root: string -let clock = 1_000 -let epochs = 0 -const journals = createTrackedJournalOpener() - -function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -function fingerprint(body: AgentJournalMessageItem): string { - return structuredAgentSessionPayloadFingerprint({ - method: 'agentSession.send', - sessionId: IDENTITY.sessionId, - fields: { body } - }) -} - -async function open(): Promise { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => (clock += 1), - mintEpoch: () => `epoch-${(epochs += 1)}` - }) -} - -function hold(journal: AgentSessionJournal, settle: UnsentSendHold = { cause: 'hostRestarted' }) { - return holdUnsentSends(journal, { fence: 0, hostInstance: HOST, hold: settle }) -} - -/** Orchestration mail as the mailbox sends it: from another agent, naming its sender. */ -const MAIL_SOURCE: AgentMessageSource = { - kind: 'agent', - senders: [ - { - party: { address: 'agent:coordinator', terminalHandle: null, orcaSessionId: null }, - name: null - } - ], - orchestration: { message: 'mail-notice', mailbox: 'agent:worker', dispatchId: null, messages: [] } -} - -async function accept( - journal: AgentSessionJournal, - id: string, - fields: { - body?: AgentJournalMessageItem - origin?: 'client' | 'host' - source?: AgentSessionMessageSource - } = {} -): Promise { - const body = fields.body ?? message(`text of ${id}`) - await journal.appendSubmission({ - clientMessageId: id, - payloadFingerprint: fingerprint(body), - body, - fence: 0, - handoverRecorded: true, - ...(fields.origin ? { origin: fields.origin } : {}), - ...(fields.source ? { source: fields.source } : {}) - }) -} - -/** Writes rows as a process that then quit, and opens the journal again as the next one. */ -async function afterRestart( - write: (journal: AgentSessionJournal) => Promise -): Promise { - const earlier = await open() - await write(earlier) - await earlier.close() - return open() -} - -/** Rewrites a stored submission's `source` as another build would have written it. */ -function storeSubmissionSourceAs(journal: AgentSessionJournal, id: string, source: unknown): void { - const seq = journal.submission(id)?.submittedSequence - const { db } = openTestJournalHostDatabase(root) - const stored = liveTestJournalRows(db, IDENTITY.sessionId).find((row) => row.seq === seq) - if (!stored) { - throw new Error(`no stored row for ${id}`) - } - const row: unknown = JSON.parse(stored.rowJson) - updateTestJournalRowJson( - db, - IDENTITY.sessionId, - stored.seq, - JSON.stringify({ ...(typeof row === 'object' ? row : {}), source }) - ) -} - -function cardOrder(journal: AgentSessionJournal): string[] { - return journal.queuedMessages - .list() - .filter((card) => card.state === 'waiting') - .map((card) => card.messageId) -} - -beforeEach(async () => { - epochs = 0 - root = await mkdtemp(join(tmpdir(), 'orca-unsent-hold-')) -}) - -afterEach(async () => { - await journals.closeAll() - await closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -describe('which sends an earlier host process left unsent are kept', () => { - it('keeps a person’s and a launch’s text, and an older build’s client send', async () => { - const journal = await afterRestart(async (earlier) => { - await accept(earlier, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) - await accept(earlier, 'launch', { origin: 'host', source: USER_MESSAGE_SOURCE }) - await accept(earlier, 'legacy-client', { origin: 'client' }) - }) - await hold(journal) - - expect(cardOrder(journal)).toEqual(['person', 'launch', 'legacy-client']) - await journal.close() - // The rejection names the card it was kept as, read back from disk. - const reopened = await open() - for (const id of ['person', 'launch', 'legacy-client']) { - expect(reopened.submission(id)).toMatchObject({ - dispatchState: 'rejected', - ...HOST_RESTARTED, - keptAsQueuedMessageId: id - }) - expect(reopened.queuedMessages.get(id)).toMatchObject({ - state: 'waiting', - holdReason: null, - hostInstance: HOST, - body: message(`text of ${id}`), - fingerprint: fingerprint(message(`text of ${id}`)), - carriedFrom: null, - queuedAt: { epoch: 'epoch-1', sequence: reopened.submission(id)?.acceptedSequence } - }) - } - }) - - it('rejects mail, a dispatch preamble, a continuation, /compact, an image, and a send no one can attribute', async () => { - const image: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [ - { type: 'text', text: 'look' }, - { type: 'image-ref', path: '/tmp/attachment.png' } - ] - } - const journal = await afterRestart(async (earlier) => { - await accept(earlier, 'mail', { origin: 'host', source: MAIL_SOURCE }) - await accept(earlier, 'dispatch', { origin: 'host' }) - await accept(earlier, 'continuation', { origin: 'host' }) - await accept(earlier, 'compact', { - origin: 'client', - source: USER_MESSAGE_SOURCE, - body: structuredAgentSessionCompactBody() - }) - await accept(earlier, 'image', { origin: 'client', source: USER_MESSAGE_SOURCE, body: image }) - await accept(earlier, 'legacy-host', { origin: 'host' }) - await accept(earlier, 'no-origin') - }) - await hold(journal) - - expect(journal.queuedMessages.list()).toEqual([]) - for (const id of [ - 'mail', - 'dispatch', - 'continuation', - 'compact', - 'image', - 'legacy-host', - 'no-origin' - ]) { - expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...HOST_RESTARTED }) - expect(journal.submission(id)).not.toHaveProperty('keptAsQueuedMessageId') - } - }) - - // Whoever sent it, the card comes back with no hold of its own, to wait under the reopen's pause. - it.each([ - { by: 'Send now', origin: 'client' as const }, - { by: 'the queue', origin: 'host' as const } - ])( - 'a card’s own hand-off by $by returns its card, and makes no second one', - async ({ origin }) => { - const journal = await afterRestart(async (earlier) => { - await earlier.queuedMessages.insert({ - messageId: 'card', - body: message('card text'), - fingerprint: fingerprint(message('card text')), - hostInstance: 'proc-1' - }) - await earlier.appendSubmission( - { - clientMessageId: 'handoff', - payloadFingerprint: fingerprint(message('card text')), - body: message('card text'), - fence: 0, - handoverRecorded: true, - origin - }, - { messageId: 'card', expect: 'waiting', settledByOp: null } - ) - }) - await hold(journal) - - expect(journal.queuedMessages.list()).toHaveLength(1) - expect(journal.queuedMessages.get('card')).toMatchObject({ - state: 'waiting', - consumedAs: null, - holdReason: null - }) - expect(journal.queuedMessages.get('handoff')).toBeNull() - } - ) - - it('leaves alone what this process accepted', async () => { - const journal = await open() - await accept(journal, 'mine', { origin: 'client', source: USER_MESSAGE_SOURCE }) - await hold(journal) - expect(journal.submission('mine')?.dispatchState).toBe('pending') - expect(journal.queuedMessages.list()).toEqual([]) - }) - - it('never keeps a queue hand-off, a card’s link, or a send with no message body', () => { - const body = message('x') - expect(unsentSendKeptAsCard({ source: USER_MESSAGE_SOURCE }, body)).toBe(body) - expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: 'agent' } }, body)).toBeNull() - // Undecodable (folded as an empty kind) and unknown kinds are never a person's. - expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: '' } }, body)).toBeNull() - expect(unsentSendKeptAsCard({ origin: 'client', source: { kind: 'orca' } }, body)).toBeNull() - expect(unsentSendKeptAsCard({ origin: 'client', queuedMessageId: 'card' }, body)).toBeNull() - expect(unsentSendKeptAsCard({ origin: 'client' }, null)).toBeNull() - }) - - // Only a row with no source at all is an older build's; a newer build's kind may name a sender - // that must not become a card. - it('never keeps a source kind this build does not know, whatever its origin', async () => { - const journal = await afterRestart(async (earlier) => { - await earlier.appendSubmission({ - clientMessageId: 'future', - payloadFingerprint: fingerprint(message('from a newer build')), - body: message('from a newer build'), - fence: 0, - handoverRecorded: true, - origin: 'client', - source: USER_MESSAGE_SOURCE - }) - storeSubmissionSourceAs(earlier, 'future', { kind: 'a-newer-kind' }) - }) - expect(journal.submission('future')?.source).toEqual({ kind: 'a-newer-kind' }) - await hold(journal) - expect(journal.queuedMessages.list()).toEqual([]) - expect(journal.submission('future')).toMatchObject({ dispatchState: 'rejected' }) - }) - - // A source with no readable kind is not a row without one: an older build's rule never applies. - it('never keeps a send whose stored source has no readable kind', async () => { - const journal = await afterRestart(async (earlier) => { - await earlier.appendSubmission({ - clientMessageId: 'unreadable', - payloadFingerprint: fingerprint(message('unreadable')), - body: message('unreadable'), - fence: 0, - handoverRecorded: true, - origin: 'client', - source: USER_MESSAGE_SOURCE - }) - storeSubmissionSourceAs(earlier, 'unreadable', {}) - }) - expect(journal.submission('unreadable')?.source).toEqual({ kind: '' }) - await hold(journal) - expect(journal.queuedMessages.list()).toEqual([]) - expect(journal.submission('unreadable')).toMatchObject({ dispatchState: 'rejected' }) - }) -}) - -describe('where kept sends go in the queue', () => { - // A Send the person asked for comes back kept among them; the queue's own hand-off stays put. - it.each([ - { by: 'Send now', origin: 'client' as const, order: ['A', 'H', 'B', 'C'] }, - { by: 'the queue', origin: 'host' as const, order: ['A', 'B', 'H', 'C'] } - ])( - 'in acceptance order, ahead of the cards already waiting, a hand-off by $by among them', - async ({ origin, order }) => { - const journal = await afterRestart(async (earlier) => { - await earlier.queuedMessages.insert({ - messageId: 'H', - body: message('H'), - fingerprint: fingerprint(message('H')), - hostInstance: 'proc-1' - }) - await earlier.queuedMessages.insert({ - messageId: 'C', - body: message('C'), - fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1' - }) - await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) - await earlier.appendSubmission( - { - clientMessageId: 'H-handoff', - payloadFingerprint: fingerprint(message('H')), - body: message('H'), - fence: 0, - handoverRecorded: true, - origin - }, - { messageId: 'H', expect: 'waiting', settledByOp: null } - ) - await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) - }) - await hold(journal) - - expect(cardOrder(journal)).toEqual(order) - } - ) - - it('a run a crash cut short is placed again with the rest, in acceptance order', async () => { - const interrupted = await afterRestart(async (earlier) => { - await earlier.queuedMessages.insert({ - messageId: 'C', - body: message('C'), - fingerprint: fingerprint(message('C')), - hostInstance: 'proc-1' - }) - await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) - await accept(earlier, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) - }) - // That run kept A, at a stale place behind C, then died before B. - await interrupted.resolveDispatch( - { - clientMessageId: 'A', - state: 'rejected', - ...HOST_RESTARTED, - fence: 0, - recovered: true, - keptAsQueuedMessageId: 'A' - }, - (db) => { - interrupted.queuedMessages.holdInTransaction(db, { - card: { - messageId: 'A', - body: message('text of A'), - fingerprint: fingerprint(message('text of A')), - hostInstance: HOST, - queuedAt: { - epoch: 'epoch-1', - sequence: interrupted.submission('A')!.acceptedSequence! - }, - position: 5 - }, - positions: [] - }) - } - ) - await interrupted.close() - const journal = await open() - await hold(journal) - - expect(cardOrder(journal)).toEqual(['A', 'B', 'C']) - }) -}) - -describe('a close of the chat', () => { - it('keeps a person’s unsent send in place, rejected as closed, by the same rule as a restart', async () => { - const journal = await open() - await accept(journal, 'person', { origin: 'client', source: USER_MESSAGE_SOURCE }) - await accept(journal, 'mail', { origin: 'host', source: MAIL_SOURCE }) - await hold(journal, { cause: 'chatClosed' }) - - expect(cardOrder(journal)).toEqual(['person']) - expect(journal.queuedMessages.get('person')).toMatchObject({ - holdReason: null, - hostInstance: HOST - }) - for (const id of ['person', 'mail']) { - expect(journal.submission(id)).toMatchObject({ dispatchState: 'rejected', ...CHAT_CLOSED }) - } - }) - - it('settles only what `which` names, leaving a later send queued', async () => { - const journal = await open() - await accept(journal, 'before', { origin: 'client', source: USER_MESSAGE_SOURCE }) - await accept(journal, 'after', { origin: 'client', source: USER_MESSAGE_SOURCE }) - await hold(journal, { - cause: 'chatClosed', - which: (submission) => submission.clientMessageId === 'before' - }) - - expect(cardOrder(journal)).toEqual(['before']) - expect(journal.submission('after')?.dispatchState).toBe('pending') - }) -}) - -describe('the order kept cards stand in', () => { - // Sequences restart with each epoch, so a card kept before a rewind has no sequence to compare. - it('a card kept before the epoch rolled stays ahead of one kept after it', async () => { - const journal = await afterRestart(async (earlier) => { - for (const id of ['m1', 'm2', 'm3']) { - await accept(earlier, id, { origin: 'host', source: MAIL_SOURCE }) - } - await accept(earlier, 'A', { origin: 'client', source: USER_MESSAGE_SOURCE }) - }) - await hold(journal) - await journal.rollEpoch('handle_forked', 0) - await accept(journal, 'B', { origin: 'client', source: USER_MESSAGE_SOURCE }) - await journal.close() - const reopened = await open() - await hold(reopened) - - expect(reopened.queuedMessages.get('A')?.queuedAt?.epoch).not.toBe( - reopened.queuedMessages.get('B')?.queuedAt?.epoch - ) - expect(cardOrder(reopened)).toEqual(['A', 'B']) - }) -}) - -describe('the source a send records', () => { - it('is folded from the row; a row without one folds without it', async () => { - const journal = await open() - await accept(journal, 'with-source', { origin: 'host', source: USER_MESSAGE_SOURCE }) - await accept(journal, 'without', { origin: 'client' }) - const folded: AgentJournalSubmission | undefined = journal.submission('with-source') - expect(folded?.source).toEqual({ kind: 'user' }) - expect(journal.submission('without')).not.toHaveProperty('source') - }) - - // Who sent it stays on the card, host-only; a submission is published to clients as it folds. - it('keeps only the kind of an agent’s source, never its senders', async () => { - const journal = await open() - await accept(journal, 'mail', { origin: 'host', source: MAIL_SOURCE }) - await journal.close() - const reopened = await open() - expect(reopened.submission('mail')?.source).toEqual({ kind: 'agent' }) - expect(JSON.stringify(reopened.submission('mail'))).not.toContain('agent:coordinator') - }) -}) diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.spec.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.spec.ts new file mode 100644 index 00000000000..6b85b184c5f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-write-queue.spec.ts @@ -0,0 +1,220 @@ +// A write has landed in the fold when its call returns. A write issued from inside a running write +// joins the line behind it, never nested. +// A read in the queue always settles: behind a write that failed, and refused once closed. + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemIdentity, + type AgentJournalMessageItem +} from '../../../shared/agent-session-journal-types' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import type { AgentSessionJournal } from './journal-store' +import { openAgentSessionJournal } from './journal-store-factory' +import { JournalWriteQueue } from './journal-write-queue' +import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerJournalWriteQueueTests(): void { + const IDENTITY = { + sessionId: 'session-1', + workspaceId: 'workspace-1', + hostId: 'host-1', + agent: 'codex', + providerHandle: codexProviderHandle('thread-1') + } as const + + const reply = (text: string): AgentJournalMessageItem => ({ + kind: 'message', + role: 'assistant', + blocks: [{ type: 'text', text }] + }) + + const item = (ordinal: number): AgentJournalItemIdentity => ({ + provider: 'codex', + threadId: 'thread-1', + turnId: 'turn-1', + ordinal + }) + + const OPTIONS = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + + let root = '' + const opened: AgentSessionJournal[] = [] + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-journal-write-queue-')) + }) + + afterEach(async () => { + await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) + closeTestJournalHostDatabases() + await rm(root, { recursive: true, force: true }) + }) + + async function idleJournal(): Promise { + const journal = await openAgentSessionJournal({ + identity: IDENTITY, + database: openTestJournalHostDatabase(root) + }) + opened.push(journal) + return journal + } + + function sequenceOf(journal: AgentSessionJournal, ordinal: number): number | undefined { + const key = agentJournalItemKey(item(ordinal)) + let found: number | undefined + journal.visitItems((itemId, sequence) => { + if (itemId === key) { + found = sequence + } + }) + return found + } + + describe('when a journal write lands', () => { + it('is in the fold when its call returns, on an idle queue', async () => { + const journal = await idleJournal() + const before = journal.cursor().sequence + + const first = journal.appendItem(item(1), reply('one'), OPTIONS) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('one')) + const second = journal.appendItem(item(2), reply('two'), OPTIONS) + expect(journal.cursor().sequence).toBe(before + 2) + expect(sequenceOf(journal, 2)).toBeGreaterThan(sequenceOf(journal, 1)!) + + await expect(Promise.all([first, second])).resolves.toHaveLength(2) + }) + + it('joins the line behind a write it was issued from inside, never nested in it', async () => { + const journal = await idleJournal() + let inner: Promise | null = null + let innerLandedDuringOuter = false + + // Issued from inside the outer write, before that write has committed its row. + const outer = journal.appendResolvedItem(() => { + inner = journal.appendItem(item(2), reply('inner'), OPTIONS) + innerLandedDuringOuter = journal.itemBody(agentJournalItemKey(item(2))) !== null + return { identity: item(1), body: reply('outer') } + }, OPTIONS) + + expect(innerLandedDuringOuter).toBe(false) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('outer')) + expect(journal.itemBody(agentJournalItemKey(item(2)))).toBeNull() + await outer + await inner + expect(sequenceOf(journal, 2)).toBe(sequenceOf(journal, 1)! + 1) + }) + + it('writes no row for a resolve that finds nothing, and keeps the line moving', async () => { + const journal = await idleJournal() + const before = journal.cursor().sequence + + await expect(journal.appendResolvedItem(() => null, OPTIONS)).resolves.toBeNull() + expect(journal.cursor().sequence).toBe(before) + await journal.appendItem(item(1), reply('after'), OPTIONS) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('after')) + }) + + it('preserves other resolver errors even when their message matches the no-row error', async () => { + const journal = await idleJournal() + const before = journal.cursor().sequence + const error = new Error('journal_item_resolved_to_nothing') + + await expect( + journal.appendResolvedItem(() => { + throw error + }, OPTIONS) + ).rejects.toBe(error) + expect(journal.cursor().sequence).toBe(before) + await journal.appendItem(item(1), reply('after'), OPTIONS) + expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('after')) + }) + }) + + describe('the journal write queue', () => { + it('runs a write before it returns when nothing is running', () => { + const queue = new JournalWriteQueue('session-1') + const ran: string[] = [] + void queue.serialize(() => { + ran.push('write') + }) + expect(ran).toEqual(['write']) + }) + + it('takes only a synchronous body', async () => { + const queue = new JournalWriteQueue('session-1') + // @ts-expect-error an await inside a write would let a later write land first + await queue.serialize(async () => undefined) + }) + + it('answers a write that throws with a rejection, never a throw', async () => { + const queue = new JournalWriteQueue('session-1') + const failed = queue.serialize(() => { + throw new Error('disk I/O error') + }) + await expect(failed).rejects.toThrow('disk I/O error') + const ran: string[] = [] + void queue.serialize(() => { + ran.push('next') + }) + expect(ran).toEqual(['next']) + }) + + it('runs a write issued from inside a running one after it, in issue order', async () => { + const queue = new JournalWriteQueue('session-1') + const ran: string[] = [] + let nested: Promise | null = null + const outer = queue.serialize(() => { + nested = queue.serialize(() => { + ran.push('nested') + }) + ran.push('outer') + }) + const after = queue.serialize(() => { + ran.push('after') + }) + expect(ran).toEqual(['outer']) + await Promise.all([outer, nested, after]) + expect(ran).toEqual(['outer', 'nested', 'after']) + }) + }) + + describe('a read in the journal write queue', () => { + it('runs at once when no write waits', () => { + const queue = new JournalWriteQueue('session-1') + const ran: string[] = [] + void queue.readInOrder(() => ran.push('read')) + expect(ran).toEqual(['read']) + }) + + it('runs behind a write that failed', async () => { + const queue = new JournalWriteQueue('session-1') + let failed: Promise = Promise.resolve() + let read: Promise = Promise.resolve('') + // Issued from inside a running write, so both join the line. + await queue.serialize(() => { + failed = queue.serialize(() => { + throw new Error('disk I/O error') + }) + read = queue.readInOrder(() => 'read') + }) + await expect(failed).rejects.toThrow('disk I/O error') + await expect(read).resolves.toBe('read') + }) + + it('is refused at once after close, not queued', async () => { + const queue = new JournalWriteQueue('session-1') + queue.markClosed() + await expect(queue.readInOrder(() => 'read')).rejects.toMatchObject({ + code: 'journal_closed' + }) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts b/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts deleted file mode 100644 index 69e4dd22f00..00000000000 --- a/src/main/native-chat/agent-session-journal/journal-write-queue.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -// A write has landed in the fold when its call returns. A write issued from inside a running write -// joins the line behind it, never nested. -// A read in the queue always settles: behind a write that failed, and refused once closed. - -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' -import { - AGENT_JOURNAL_THREAD_SCOPE, - type AgentJournalItemIdentity, - type AgentJournalMessageItem -} from '../../../shared/agent-session-journal-types' -import { - closeTestJournalHostDatabases, - openTestJournalHostDatabase -} from './journal-host-database-test-support' -import type { AgentSessionJournal } from './journal-store' -import { openAgentSessionJournal } from './journal-store-factory' -import { JournalWriteQueue } from './journal-write-queue' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY = { - sessionId: 'session-1', - workspaceId: 'workspace-1', - hostId: 'host-1', - agent: 'codex', - providerHandle: codexProviderHandle('thread-1') -} as const - -const reply = (text: string): AgentJournalMessageItem => ({ - kind: 'message', - role: 'assistant', - blocks: [{ type: 'text', text }] -}) - -const item = (ordinal: number): AgentJournalItemIdentity => ({ - provider: 'codex', - threadId: 'thread-1', - turnId: 'turn-1', - ordinal -}) - -const OPTIONS = { fence: 1, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - -let root = '' -const opened: AgentSessionJournal[] = [] - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-journal-write-queue-')) -}) - -afterEach(async () => { - await Promise.allSettled(opened.splice(0).map((journal) => journal.close())) - closeTestJournalHostDatabases() - await rm(root, { recursive: true, force: true }) -}) - -async function idleJournal(): Promise { - const journal = await openAgentSessionJournal({ - identity: IDENTITY, - database: openTestJournalHostDatabase(root) - }) - opened.push(journal) - return journal -} - -function sequenceOf(journal: AgentSessionJournal, ordinal: number): number | undefined { - const key = agentJournalItemKey(item(ordinal)) - let found: number | undefined - journal.visitItems((itemId, sequence) => { - if (itemId === key) { - found = sequence - } - }) - return found -} - -describe('when a journal write lands', () => { - it('is in the fold when its call returns, on an idle queue', async () => { - const journal = await idleJournal() - const before = journal.cursor().sequence - - const first = journal.appendItem(item(1), reply('one'), OPTIONS) - expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('one')) - const second = journal.appendItem(item(2), reply('two'), OPTIONS) - expect(journal.cursor().sequence).toBe(before + 2) - expect(sequenceOf(journal, 2)).toBeGreaterThan(sequenceOf(journal, 1)!) - - await expect(Promise.all([first, second])).resolves.toHaveLength(2) - }) - - it('joins the line behind a write it was issued from inside, never nested in it', async () => { - const journal = await idleJournal() - let inner: Promise | null = null - let innerLandedDuringOuter = false - - // Issued from inside the outer write, before that write has committed its row. - const outer = journal.appendResolvedItem(() => { - inner = journal.appendItem(item(2), reply('inner'), OPTIONS) - innerLandedDuringOuter = journal.itemBody(agentJournalItemKey(item(2))) !== null - return { identity: item(1), body: reply('outer') } - }, OPTIONS) - - expect(innerLandedDuringOuter).toBe(false) - expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('outer')) - expect(journal.itemBody(agentJournalItemKey(item(2)))).toBeNull() - await outer - await inner - expect(sequenceOf(journal, 2)).toBe(sequenceOf(journal, 1)! + 1) - }) - - it('writes no row for a resolve that finds nothing, and keeps the line moving', async () => { - const journal = await idleJournal() - const before = journal.cursor().sequence - - await expect(journal.appendResolvedItem(() => null, OPTIONS)).resolves.toBeNull() - expect(journal.cursor().sequence).toBe(before) - await journal.appendItem(item(1), reply('after'), OPTIONS) - expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('after')) - }) - - it('preserves other resolver errors even when their message matches the no-row error', async () => { - const journal = await idleJournal() - const before = journal.cursor().sequence - const error = new Error('journal_item_resolved_to_nothing') - - await expect( - journal.appendResolvedItem(() => { - throw error - }, OPTIONS) - ).rejects.toBe(error) - expect(journal.cursor().sequence).toBe(before) - await journal.appendItem(item(1), reply('after'), OPTIONS) - expect(journal.itemBody(agentJournalItemKey(item(1)))).toEqual(reply('after')) - }) -}) - -describe('the journal write queue', () => { - it('runs a write before it returns when nothing is running', () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - void queue.serialize(() => { - ran.push('write') - }) - expect(ran).toEqual(['write']) - }) - - it('takes only a synchronous body', async () => { - const queue = new JournalWriteQueue('session-1') - // @ts-expect-error an await inside a write would let a later write land first - await queue.serialize(async () => undefined) - }) - - it('answers a write that throws with a rejection, never a throw', async () => { - const queue = new JournalWriteQueue('session-1') - const failed = queue.serialize(() => { - throw new Error('disk I/O error') - }) - await expect(failed).rejects.toThrow('disk I/O error') - const ran: string[] = [] - void queue.serialize(() => { - ran.push('next') - }) - expect(ran).toEqual(['next']) - }) - - it('runs a write issued from inside a running one after it, in issue order', async () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - let nested: Promise | null = null - const outer = queue.serialize(() => { - nested = queue.serialize(() => { - ran.push('nested') - }) - ran.push('outer') - }) - const after = queue.serialize(() => { - ran.push('after') - }) - expect(ran).toEqual(['outer']) - await Promise.all([outer, nested, after]) - expect(ran).toEqual(['outer', 'nested', 'after']) - }) -}) - -describe('a read in the journal write queue', () => { - it('runs at once when no write waits', () => { - const queue = new JournalWriteQueue('session-1') - const ran: string[] = [] - void queue.readInOrder(() => ran.push('read')) - expect(ran).toEqual(['read']) - }) - - it('runs behind a write that failed', async () => { - const queue = new JournalWriteQueue('session-1') - let failed: Promise = Promise.resolve() - let read: Promise = Promise.resolve('') - // Issued from inside a running write, so both join the line. - await queue.serialize(() => { - failed = queue.serialize(() => { - throw new Error('disk I/O error') - }) - read = queue.readInOrder(() => 'read') - }) - await expect(failed).rejects.toThrow('disk I/O error') - await expect(read).resolves.toBe('read') - }) - - it('is refused at once after close, not queued', async () => { - const queue = new JournalWriteQueue('session-1') - queue.markClosed() - await expect(queue.readInOrder(() => 'read')).rejects.toMatchObject({ code: 'journal_closed' }) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.spec.ts new file mode 100644 index 00000000000..ba7a26a1332 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.spec.ts @@ -0,0 +1,225 @@ +// Draft bookkeeping never vetoes a journal row: a failing draft transition +// rolls back alone and the next open re-derives it, and a draft table an +// earlier build created gains the columns this build writes. + +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 { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { journalDatabasePath } from './journal-host-database' +import { JournalQueuedMessages } from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerQueuedMessageBookkeepingFailureTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + const REFUSAL = agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { + detail: { text: 'Claude refused this payload', audience: 'person' } + }), + { surface: 'rejection' } + ) + + const BODY: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] + } + + let root: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) + } + + async function queueAndConsume(journal: AgentSessionJournal, messageId: string): Promise { + const body: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'queued text' }] + } + await journal.queuedMessages.insert({ + messageId, + body, + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) + await journal.appendSubmission( + { + clientMessageId: `sub-${messageId}`, + payloadFingerprint: `fp-${messageId}`, + body, + fence: 0, + handoverRecorded: true + }, + { messageId, expect: 'waiting', settledByOp: null } + ) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-bookkeeping-')) + clock = 1_000 + }) + + afterEach(async () => { + vi.restoreAllMocks() + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('draft bookkeeping inside a journal append', () => { + it('a throwing draft transition still commits the rejection row, and the next open recovers the draft', async () => { + let journal = await open() + await queueAndConsume(journal, 'draft-1') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementationOnce(() => { + throw new Error('table queued_messages has no column named returned_rejection') + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...REFUSAL, + fence: 0 + }) + // The journal's own answer stands: Stop, failed starts and refusals depend on it. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + expect(warn).toHaveBeenCalledWith( + '[journal-append] row bookkeeping skipped:', + expect.objectContaining({ kind: 'dispatch' }) + ) + await journal.close() + journal = await open() + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + returnedReason: REFUSAL.reason, + returnedRejection: { kind: 'providerRejected' } + }) + }) + + it('an older draft table without a later column is healed at open, so a refusal returns the card', async () => { + const first = await open() + await first.close() + // The table is healed when the host opens its database, so the host restarts around the edit. + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + db.exec('DROP TABLE queued_messages') + // The shape an earlier build of the draft table wrote: no returned_rejection. + db.exec(`CREATE TABLE queued_messages ( + session_id TEXT NOT NULL, message_id TEXT NOT NULL, position INTEGER NOT NULL, + body_json TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at INTEGER NOT NULL, + host_instance TEXT NOT NULL, state TEXT NOT NULL, hold_reason TEXT, + returned_reason TEXT, settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT, + PRIMARY KEY (session_id, message_id))`) + db.close() + const journal = await open() + await queueAndConsume(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...REFUSAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + returnedRejection: { kind: 'providerRejected' } + }) + }) + + describe('a failed COMMIT', () => { + /** The append's own COMMIT fails once, after its hooks ran. */ + function failNextCommit() { + const exec = Database.prototype.exec + let armed = true + return vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( + this: Database, + sql: string + ) { + if (armed && sql === 'COMMIT') { + armed = false + throw new Error('SQLITE_FULL') + } + return exec.call(this, sql) + }) + } + + async function consumeFailingCommit(journal: AgentSessionJournal): Promise { + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body: BODY, + fingerprint: 'fp-draft-1', + hostInstance: 'proc-1' + }) + expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) + const commit = failNextCommit() + try { + await expect( + journal.appendSubmission( + { + clientMessageId: 'sub-draft-1', + payloadFingerprint: 'fp-draft-1', + body: BODY, + fence: 0 + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toThrow('SQLITE_FULL') + } finally { + commit.mockRestore() + } + } + + it('leaves no uncommitted draft state cached: the per-row hook reads drafts only for an echo', async () => { + const journal = await open() + const list = vi.spyOn(JournalQueuedMessages.prototype, 'list') + await consumeFailingCommit(journal) + // Once by the test itself before the append; never inside it. + expect(list).toHaveBeenCalledTimes(1) + list.mockRestore() + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + + it('invalidates what any read inside the rolled-back transaction cached', async () => { + const journal = await open() + // Some other bookkeeping reads the list inside the transaction, after the consume wrote. + vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation( + function (this: JournalQueuedMessages) { + this.list() + } + ) + await consumeFailingCommit(journal) + expect(journal.queuedMessages.list()).toMatchObject([ + { messageId: 'draft-1', state: 'waiting' } + ]) + }) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts b/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts deleted file mode 100644 index bc8478602d0..00000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-bookkeeping-failure.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -// Draft bookkeeping never vetoes a journal row: a failing draft transition -// rolls back alone and the next open re-derives it, and a draft table an -// earlier build created gains the columns this build writes. - -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 { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import Database from '../../sqlite/sync-database' -import { journalDatabasePath } from './journal-host-database' -import { JournalQueuedMessages } from './journal-queued-messages' -import type { AgentSessionJournal } from './journal-store' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener -} from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-q', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) -} -const REFUSAL = agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { - detail: { text: 'Claude refused this payload', audience: 'person' } - }), - { surface: 'rejection' } -) - -const BODY: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'queued text' }] -} - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function open(): Promise { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => ++clock, - mintEpoch: () => `epoch-${clock}` - }) -} - -async function queueAndConsume(journal: AgentSessionJournal, messageId: string): Promise { - const body: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'queued text' }] - } - await journal.queuedMessages.insert({ - messageId, - body, - fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' - }) - await journal.appendSubmission( - { - clientMessageId: `sub-${messageId}`, - payloadFingerprint: `fp-${messageId}`, - body, - fence: 0, - handoverRecorded: true - }, - { messageId, expect: 'waiting', settledByOp: null } - ) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-bookkeeping-')) - clock = 1_000 -}) - -afterEach(async () => { - vi.restoreAllMocks() - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('draft bookkeeping inside a journal append', () => { - it('a throwing draft transition still commits the rejection row, and the next open recovers the draft', async () => { - let journal = await open() - await queueAndConsume(journal, 'draft-1') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementationOnce(() => { - throw new Error('table queued_messages has no column named returned_rejection') - }) - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...REFUSAL, - fence: 0 - }) - // The journal's own answer stands: Stop, failed starts and refusals depend on it. - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - expect(warn).toHaveBeenCalledWith( - '[journal-append] row bookkeeping skipped:', - expect.objectContaining({ kind: 'dispatch' }) - ) - await journal.close() - journal = await open() - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'returned', - returnedReason: REFUSAL.reason, - returnedRejection: { kind: 'providerRejected' } - }) - }) - - it('an older draft table without a later column is healed at open, so a refusal returns the card', async () => { - const first = await open() - await first.close() - // The table is healed when the host opens its database, so the host restarts around the edit. - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - db.exec('DROP TABLE queued_messages') - // The shape an earlier build of the draft table wrote: no returned_rejection. - db.exec(`CREATE TABLE queued_messages ( - session_id TEXT NOT NULL, message_id TEXT NOT NULL, position INTEGER NOT NULL, - body_json TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at INTEGER NOT NULL, - host_instance TEXT NOT NULL, state TEXT NOT NULL, hold_reason TEXT, - returned_reason TEXT, settled_at INTEGER, settled_by_op TEXT, consumed_as TEXT, - PRIMARY KEY (session_id, message_id))`) - db.close() - const journal = await open() - await queueAndConsume(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...REFUSAL, - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'returned', - returnedRejection: { kind: 'providerRejected' } - }) - }) - - describe('a failed COMMIT', () => { - /** The append's own COMMIT fails once, after its hooks ran. */ - function failNextCommit() { - const exec = Database.prototype.exec - let armed = true - return vi.spyOn(Database.prototype, 'exec').mockImplementation(function ( - this: Database, - sql: string - ) { - if (armed && sql === 'COMMIT') { - armed = false - throw new Error('SQLITE_FULL') - } - return exec.call(this, sql) - }) - } - - async function consumeFailingCommit(journal: AgentSessionJournal): Promise { - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body: BODY, - fingerprint: 'fp-draft-1', - hostInstance: 'proc-1' - }) - expect(journal.queuedMessages.list()).toMatchObject([{ state: 'waiting' }]) - const commit = failNextCommit() - try { - await expect( - journal.appendSubmission( - { - clientMessageId: 'sub-draft-1', - payloadFingerprint: 'fp-draft-1', - body: BODY, - fence: 0 - }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } - ) - ).rejects.toThrow('SQLITE_FULL') - } finally { - commit.mockRestore() - } - } - - it('leaves no uncommitted draft state cached: the per-row hook reads drafts only for an echo', async () => { - const journal = await open() - const list = vi.spyOn(JournalQueuedMessages.prototype, 'list') - await consumeFailingCommit(journal) - // Once by the test itself before the append; never inside it. - expect(list).toHaveBeenCalledTimes(1) - list.mockRestore() - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.list()).toMatchObject([ - { messageId: 'draft-1', state: 'waiting' } - ]) - }) - - it('invalidates what any read inside the rolled-back transaction cached', async () => { - const journal = await open() - // Some other bookkeeping reads the list inside the transaction, after the consume wrote. - vi.spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction').mockImplementation(function ( - this: JournalQueuedMessages - ) { - this.list() - }) - await consumeFailingCommit(journal) - expect(journal.queuedMessages.list()).toMatchObject([ - { messageId: 'draft-1', state: 'waiting' } - ]) - }) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts new file mode 100644 index 00000000000..881c9dbe806 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.spec.ts @@ -0,0 +1,201 @@ +// A draft sent back to waiting after a handed-over hand-off was rejected as +// never delivered is withdrawn when the provider echoes that message: the +// first send reached the agent, so sending it again would repeat it. + +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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' +import { JournalQueuedMessages } from './journal-queued-messages' +import type { AgentSessionJournal } from './journal-store' +import { createTrackedJournalOpener } from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerQueuedMessageDeliveredEchoTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + + let root: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } + + function echo(journal: AgentSessionJournal, uuid: string, text: string) { + return journal.appendItem({ provider: 'claude', sessionId: 'native-1', uuid }, message(text), { + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + } + + /** A draft consumed and handed to the agent, then rejected as never delivered (the provider + * confirmed a Stop withdrew it): back to waiting. `handedOver: false` rejects it before + * hand-over instead, which proves it was never written. */ + async function withdrawnDraft( + text: string, + options: { handedOver: boolean } = { handedOver: true } + ): Promise { + const journal = await open() + await handOffAndReject(journal, text, options) + return journal + } + + function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) + } + + async function handOffAndReject( + journal: AgentSessionJournal, + text: string, + options: { handedOver: boolean } = { handedOver: true } + ): Promise { + const body = message(text) + const fingerprint = agentSessionSendBodyFingerprint(IDENTITY.sessionId, body) + await journal.queuedMessages.insert({ + messageId: 'draft-1', + body, + fingerprint, + hostInstance: 'p' + }) + await journal.appendSubmission( + { + clientMessageId: 'sub-draft-1', + payloadFingerprint: fingerprint, + body, + fence: 0, + handoverRecorded: true + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + if (options.handedOver) { + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'pending', + fence: 0, + turnScope: AGENT_JOURNAL_THREAD_SCOPE + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...STOP_WITHDRAWAL, + fence: 0 + }) + } else { + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + } + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + consumedAs: null + }) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-echo-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe("a waiting draft whose 'never delivered' claim an echo disproves", () => { + it('is withdrawn when the provider echoes the message it was withdrawn from', async () => { + const journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'withdrawn', + settledByOp: null + }) + // The rejection stays terminal: the echo is kept apart, not folded into it. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.snapshot().items.map((item) => item.itemId)).toHaveLength(2) + }) + + it('stays waiting when the rejected hand-off never reached the agent: the echo is some other message', async () => { + const journal = await withdrawnDraft('did it land?', { handedOver: false }) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('stays waiting for an echo of some other text', async () => { + const journal = await withdrawnDraft('did it land?') + await echo(journal, 'echo-1', 'something else') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('stays waiting when a live send of the same text claims the echo', async () => { + const journal = await withdrawnDraft('did it land?') + const body = message('did it land?') + await journal.appendSubmission({ + clientMessageId: 'typed-again', + payloadFingerprint: agentSessionSendBodyFingerprint(IDENTITY.sessionId, body), + body, + fence: 0, + handoverRecorded: true + }) + await echo(journal, 'echo-1', 'did it land?') + expect(journal.submission('typed-again')?.dispatchState).toBe('accepted') + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + describe('when the per-row hook was skipped', () => { + it('the re-derivation withdraws it from the echo already in the journal, and at reopen', async () => { + let journal = await withdrawnDraft('did it land?') + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const hook = vi + .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') + .mockImplementationOnce(() => { + throw new Error('bookkeeping failed') + }) + try { + await echo(journal, 'echo-1', 'did it land?') + } finally { + hook.mockRestore() + warn.mockRestore() + } + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(true) + // The drain's heal, before the draft could send again. + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('an unclaimed echo from before the hand-off proves nothing about it', async () => { + const journal = await open() + await echo(journal, 'typed-in-the-agent', 'did it land?') + await handOffAndReject(journal, 'did it land?') + expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) + await journal.queuedMessages.settleOwed() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts b/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts deleted file mode 100644 index e6905e84846..00000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-delivered-echo.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -// A draft sent back to waiting after a handed-over hand-off was rejected as -// never delivered is withdrawn when the provider echoes that message: the -// first send reached the agent, so sending it again would repeat it. - -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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' -import { JournalQueuedMessages } from './journal-queued-messages' -import type { AgentSessionJournal } from './journal-store' -import { createTrackedJournalOpener } from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-q', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) -} -const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' -}) - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -function echo(journal: AgentSessionJournal, uuid: string, text: string) { - return journal.appendItem({ provider: 'claude', sessionId: 'native-1', uuid }, message(text), { - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) -} - -/** A draft consumed and handed to the agent, then rejected as never delivered (the provider - * confirmed a Stop withdrew it): back to waiting. `handedOver: false` rejects it before - * hand-over instead, which proves it was never written. */ -async function withdrawnDraft( - text: string, - options: { handedOver: boolean } = { handedOver: true } -): Promise { - const journal = await open() - await handOffAndReject(journal, text, options) - return journal -} - -function open(): Promise { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => ++clock, - mintEpoch: () => `epoch-${clock}` - }) -} - -async function handOffAndReject( - journal: AgentSessionJournal, - text: string, - options: { handedOver: boolean } = { handedOver: true } -): Promise { - const body = message(text) - const fingerprint = agentSessionSendBodyFingerprint(IDENTITY.sessionId, body) - await journal.queuedMessages.insert({ - messageId: 'draft-1', - body, - fingerprint, - hostInstance: 'p' - }) - await journal.appendSubmission( - { - clientMessageId: 'sub-draft-1', - payloadFingerprint: fingerprint, - body, - fence: 0, - handoverRecorded: true - }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } - ) - if (options.handedOver) { - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'pending', - fence: 0, - turnScope: AGENT_JOURNAL_THREAD_SCOPE - }) - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...STOP_WITHDRAWAL, - fence: 0 - }) - } else { - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - } - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - consumedAs: null - }) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-echo-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe("a waiting draft whose 'never delivered' claim an echo disproves", () => { - it('is withdrawn when the provider echoes the message it was withdrawn from', async () => { - const journal = await withdrawnDraft('did it land?') - await echo(journal, 'echo-1', 'did it land?') - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'withdrawn', - settledByOp: null - }) - // The rejection stays terminal: the echo is kept apart, not folded into it. - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - expect(journal.snapshot().items.map((item) => item.itemId)).toHaveLength(2) - }) - - it('stays waiting when the rejected hand-off never reached the agent: the echo is some other message', async () => { - const journal = await withdrawnDraft('did it land?', { handedOver: false }) - await echo(journal, 'echo-1', 'did it land?') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('stays waiting for an echo of some other text', async () => { - const journal = await withdrawnDraft('did it land?') - await echo(journal, 'echo-1', 'something else') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('stays waiting when a live send of the same text claims the echo', async () => { - const journal = await withdrawnDraft('did it land?') - const body = message('did it land?') - await journal.appendSubmission({ - clientMessageId: 'typed-again', - payloadFingerprint: agentSessionSendBodyFingerprint(IDENTITY.sessionId, body), - body, - fence: 0, - handoverRecorded: true - }) - await echo(journal, 'echo-1', 'did it land?') - expect(journal.submission('typed-again')?.dispatchState).toBe('accepted') - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - describe('when the per-row hook was skipped', () => { - it('the re-derivation withdraws it from the echo already in the journal, and at reopen', async () => { - let journal = await withdrawnDraft('did it land?') - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const hook = vi - .spyOn(JournalQueuedMessages.prototype, 'onRowInTransaction') - .mockImplementationOnce(() => { - throw new Error('bookkeeping failed') - }) - try { - await echo(journal, 'echo-1', 'did it land?') - } finally { - hook.mockRestore() - warn.mockRestore() - } - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(true) - // The drain's heal, before the draft could send again. - await journal.queuedMessages.settleOwed() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) - await journal.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - }) - - it('an unclaimed echo from before the hand-off proves nothing about it', async () => { - const journal = await open() - await echo(journal, 'typed-in-the-agent', 'did it land?') - await handOffAndReject(journal, 'did it land?') - expect(journal.queuedMessages.deliveredByEchoOwed()).toBe(false) - await journal.queuedMessages.settleOwed() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts new file mode 100644 index 00000000000..328960d45e5 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-pause.spec.ts @@ -0,0 +1,756 @@ +// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, an accepted +// turn is a row, and /clear records the exact waiting cards. No separate pause state is stored. + +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 { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import Database from '../../sqlite/sync-database' +import { journalDatabasePath } from './journal-host-database' +import { + createTrackedJournalOpener, + liveTestJournalRows, + updateTestJournalRowJson +} from './journal-host-database-test-support' +import { QueuedMessageNotConsumableError } from './queued-message-consume-error' +import { applyJournalRow, createJournalReducerState } from './journal-reducer' +import { parseJournalRow } from './journal-row-schema' +import type { AgentSessionJournal } from './journal-store' +import { + deriveQueuePauses, + nextSendableQueuedCard, + queuePauseHolding, + resumableQueuePause +} from './queued-message-pause' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerQueuedMessagePauseTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-p', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + const HOST = 'proc-1' + + let root: string + let clock = 1_000 + const journals = createTrackedJournalOpener() + + function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } + + function open(): Promise { + return journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: () => ++clock, + mintEpoch: () => `epoch-${clock}` + }) + } + + function queueDraft(journal: AgentSessionJournal, messageId: string) { + return journal.queuedMessages.insert({ + messageId, + body: message(messageId), + fingerprint: `fp-${messageId}`, + hostInstance: HOST + }) + } + + function clearContext(journal: AgentSessionJournal) { + return journal.context.clear( + { operationId: `clear-${++clock}`, afterFence: 0, clearedAt: clock }, + { write: () => {}, committed: () => {} }, + `caller-clear-${clock}` + ) + } + + /** A turn sent, then accepted by the provider. */ + async function turn(journal: AgentSessionJournal, id: string, accept = true): Promise { + await journal.appendSubmission({ + clientMessageId: id, + payloadFingerprint: `fp-${id}`, + body: message(id), + fence: 0, + handoverRecorded: true + }) + if (accept) { + await acceptTurn(journal, id) + } + } + + function acceptTurn(journal: AgentSessionJournal, id: string) { + return journal.resolveDispatch({ + clientMessageId: id, + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: `echo-${id}` }, + fence: 0 + }) + } + + /** A person's Stop taking effect. */ + function userStop(journal: AgentSessionJournal) { + return journal.appendStopEvent({ reason: 'user-stop' }, 0) + } + + function reason(journal: AgentSessionJournal): string | null { + return journal.queuedMessages.pauses()[0]?.reason ?? null + } + + /** Each card, and whether a pause in force holds it. */ + function held(journal: AgentSessionJournal): [string, boolean][] { + const pauses = journal.queuedMessages.pauses() + return journal.queuedMessages + .list() + .filter((card) => card.state === 'waiting') + .map((card) => [card.messageId, queuePauseHolding(pauses, card) !== undefined]) + } + + /** The Stop events stored in the live epoch, oldest first. */ + function stopEvents(): unknown[] { + const db = new Database(journalDatabasePath(root), { readonly: true }) + try { + return liveTestJournalRows(db, IDENTITY.sessionId).flatMap((stored) => { + const parsed = parseJournalRow(stored.rowJson) + return parsed.ok && parsed.row.kind === 'tombstone' && parsed.row.stopEvent + ? [parsed.row.stopEvent] + : [] + }) + } finally { + db.close() + } + } + + /** Rewrites one key of each row at a sequence, as a corrupt write would leave it. */ + function rewriteRows(keys: Record): void { + const db = new Database(journalDatabasePath(root)) + try { + for (const stored of liveTestJournalRows(db, IDENTITY.sessionId)) { + const rewrite = keys[stored.seq] + if (rewrite) { + const row: Record = JSON.parse(stored.rowJson) + row[rewrite[0]] = rewrite[1] + updateTestJournalRowJson(db, IDENTITY.sessionId, stored.seq, JSON.stringify(row)) + } + } + } finally { + db.close() + } + } + + /** Tables that could store a pause: none, the journal rows are the only record. */ + function pauseTables(): number { + const db = new Database(journalDatabasePath(root), { readonly: true }) + try { + return db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%pause%'") + .all().length + } finally { + db.close() + } + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queue-pause-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe("the queue's pause, derived from the journal", () => { + it("a person's Stop event pauses the queue, even with no card yet, survives reopen, and stores nothing", async () => { + let journal = await open() + await userStop(journal) + expect(reason(journal)).toBe('stopped') + await queueDraft(journal, 'draft-1') + await journal.close() + journal = await open() + expect(reason(journal)).toBe('stopped') + expect(pauseTables()).toBe(0) + }) + + it("any turn sent after the Stop and accepted lifts it, Orca's own mail included", async () => { + const journal = await open() + await turn(journal, 'before-stop', false) + await userStop(journal) + // Sent before the Stop: its acceptance now does not end a Stop that came after it. + await acceptTurn(journal, 'before-stop') + expect(reason(journal)).toBe('stopped') + // Orchestration mail: sent, not yet accepted, lifts nothing; accepted, it lifts the pause. + await turn(journal, 'mail', false) + expect(reason(journal)).toBe('stopped') + await acceptTurn(journal, 'mail') + expect(reason(journal)).toBeNull() + }) + + it('a later Stop is the latest, and a Resume row lifts it', async () => { + const journal = await open() + await userStop(journal) + await turn(journal, 'typed') + expect(reason(journal)).toBeNull() + await userStop(journal) + expect(reason(journal)).toBe('stopped') + await journal.appendQueueResume(0) + expect(reason(journal)).toBeNull() + await userStop(journal) + expect(reason(journal)).toBe('stopped') + expect(pauseTables()).toBe(0) + }) + + it('a card kept by /clear stays paused until Resume in the same journal', async () => { + let journal = await open() + await queueDraft(journal, 'carried') + await clearContext(journal) + expect(reason(journal)).toBe('cleared') + await journal.close() + journal = await open() + expect(reason(journal)).toBe('cleared') + await journal.appendQueueResume(0) + expect(reason(journal)).toBeNull() + expect(pauseTables()).toBe(0) + }) + + it('any accepted turn after /clear lifts its pause, a launch prompt Orca sent included', async () => { + const journal = await open() + await queueDraft(journal, 'carried') + await clearContext(journal) + await turn(journal, 'launch', false) + expect(reason(journal)).toBe('cleared') + await acceptTurn(journal, 'launch') + expect(reason(journal)).toBeNull() + }) + + it('rides a tombstone of an id no item takes, so an older build reads it and changes nothing', async () => { + const journal = await open() + await turn(journal, 'typed') + await journal.appendStopEvent( + { reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, + 0 + ) + const db = new Database(journalDatabasePath(root), { readonly: true }) + const stored = liveTestJournalRows(db, IDENTITY.sessionId) + db.close() + const parsed = parseJournalRow(stored.at(-1)?.rowJson ?? '') + expect(parsed).toMatchObject({ + ok: true, + row: { + kind: 'tombstone', + stopEvent: { reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' } + } + }) + if (!parsed.ok || parsed.row.kind !== 'tombstone') { + throw new Error('expected a tombstone row') + } + expect(parsed.row.stopEvent?.at).toBe(parsed.row.ts) + // An older build ignores the unknown key: the row is an ordinary removal of nothing. + const { stopEvent: _ignored, ...asOlderBuildReadsIt } = parsed.row + const state = createJournalReducerState(IDENTITY.sessionId, parsed.row.epoch) + for (const row of stored.slice(0, -1)) { + const earlier = parseJournalRow(row.rowJson) + if (earlier.ok) { + applyJournalRow(state, earlier.row) + } + } + const items = [...state.items.keys()] + const submissions = [...state.submissions.keys()] + applyJournalRow(state, asOlderBuildReadsIt) + expect([...state.items.keys()]).toEqual(items) + expect([...state.submissions.keys()]).toEqual(submissions) + }) + + it('a Stop or Resume row holding a value no build writes is ignored on read, never trusted', async () => { + let journal = await open() + await queueDraft(journal, 'held') + /** Appends a row, then reopens with one of its keys rewritten as a corrupt write would. */ + const corrupted = async ( + append: Promise<{ sequence: number }>, + key: string, + value: unknown + ) => { + const { sequence } = await append + await journal.close() + rewriteRows({ [sequence]: [key, value] }) + journal = await open() + } + const malformed = [null, 'stopped', { at: 1 }] + // Pauses nothing as the latest Stop row. + for (const value of malformed) { + await corrupted(userStop(journal), 'stopEvent', value) + expect(reason(journal)).toBeNull() + } + // Ends nothing after a person's Stop: neither as a later Stop nor as a Resume. + await userStop(journal) + for (const value of malformed) { + await corrupted(userStop(journal), 'stopEvent', value) + expect(reason(journal)).toBe('stopped') + } + await corrupted(journal.appendQueueResume(0), 'queueResume', 'yes') + expect(reason(journal)).toBe('stopped') + expect(held(journal)).toEqual([['held', true]]) + }) + + it("the queue's own consume is refused in its transaction while the pause holds the card; Send-now is not", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await userStop(journal) + const consume = (id: string, automatic: boolean) => + journal.appendSubmission( + { + clientMessageId: id, + payloadFingerprint: 'fp-draft-1', + body: message('draft-1'), + fence: 0, + handoverRecorded: true + }, + { + messageId: 'draft-1', + expect: 'waiting', + settledByOp: null, + hostInstance: HOST, + ...(automatic ? { yieldsToPause: true as const } : {}) + } + ) + await expect(consume('drain-1', true)).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.submission('drain-1')).toBeUndefined() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + await consume('send-now-1', false) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + }) + + it("a rewind's epoch replacement restates a Stop still pausing, and not one a turn ended", async () => { + const journal = await open() + await userStop(journal) + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBe('stopped') + await turn(journal, 'typed') + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBeNull() + }) + + it("only a person's Stop pauses: a host's, a close's or an unknown reason holds nothing", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + for (const reason of ['user-close', 'host-stop', 'evict'] as const) { + await journal.appendStopEvent({ reason }, 0) + } + // A newer build's reason this one does not know. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately outside the type, as a newer build could write it. + await journal.appendStopEvent({ reason: 'future-reason' as 'evict' }, 0) + expect(reason(journal)).toBeNull() + }) + + it("a later Stop of any reason ends a person's Stop, without pausing itself", async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + for (const later of ['host-stop', 'evict', 'user-close'] as const) { + await userStop(journal) + expect(held(journal)).toEqual([['draft-1', true]]) + await journal.appendStopEvent({ reason: later }, 0) + expect(reason(journal)).toBeNull() + expect(held(journal)).toEqual([['draft-1', false]]) + } + }) + + it("a person's Stop with no later Stop event is not lifted", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await userStop(journal) + // An eviction writer must write only when it ends a running turn, or it would lift this pause. + expect(reason(journal)).toBe('stopped') + expect(held(journal)).toEqual([['held', true]]) + }) + + it('the restated Stop is the same event: its reason, turn, caller and time', async () => { + const journal = await open() + await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) + const stopped = stopEvents() + await journal.replaceEpochItems('handle_forked', 0, []) + expect(stopEvents()).toEqual(stopped) + expect(stopped).toEqual([ + { reason: 'user-stop', turnId: 'turn-7', caller: 'phone', at: expect.any(Number) } + ]) + }) + + it.each([ + ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'typed')], + ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] + ])('a /clear pause %s already lifted stays lifted across a rewind', async (_name, lift) => { + const journal = await open() + await queueDraft(journal, 'carried') + await clearContext(journal) + await lift(journal) + expect(reason(journal)).toBeNull() + await journal.replaceEpochItems('handle_forked', 0, []) + expect(reason(journal)).toBeNull() + }) + + it('a lifted /clear pause and a later Stop are both restated, the Stop still in force', async () => { + const journal = await open() + await queueDraft(journal, 'carried') + await clearContext(journal) + await turn(journal, 'typed') + await userStop(journal) + await journal.replaceEpochItems('handle_forked', 0, []) + expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) + await journal.appendQueueResume(0) + expect(reason(journal)).toBeNull() + }) + }) + + describe('which cards a pause holds', () => { + it('only cards queued before the Stop event: one queued after it is a new instruction', async () => { + let journal = await open() + await queueDraft(journal, 'before') + await userStop(journal) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] + ]) + await journal.close() + journal = await open() + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] + ]) + }) + + it('a steer the Stop withdrew comes back in its own place, and is held', async () => { + const journal = await open() + await queueDraft(journal, 'steered') + await journal.appendSubmission( + { + clientMessageId: 'send-now-1', + payloadFingerprint: 'fp-steered', + body: message('steered'), + fence: 0, + handoverRecorded: true + }, + { messageId: 'steered', expect: 'waiting', settledByOp: null, hostInstance: HOST } + ) + await userStop(journal) + await queueDraft(journal, 'after') + await journal.resolveDispatch({ + clientMessageId: 'send-now-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), + fence: 0 + }) + expect(held(journal)).toEqual([ + ['steered', true], + ['after', false] + ]) + }) + + it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { + const journal = await open() + await queueDraft(journal, 'held') + await userStop(journal) + await queueDraft(journal, 'newer') + await expect( + journal.appendSubmission( + { + clientMessageId: 'drain-newer', + payloadFingerprint: 'fp-newer', + body: message('newer'), + fence: 0, + handoverRecorded: true + }, + { + messageId: 'newer', + expect: 'waiting', + settledByOp: null, + hostInstance: HOST, + yieldsToPause: true + } + ) + ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') + }) + + it("'cleared' holds the cards already queued, not one typed after it", async () => { + const journal = await open() + await queueDraft(journal, 'carried-1') + await queueDraft(journal, 'carried-2') + await clearContext(journal) + await queueDraft(journal, 'typed-here') + expect(held(journal)).toEqual([ + ['carried-1', true], + ['carried-2', true], + ['typed-here', false] + ]) + }) + }) + + describe("a reopen's pause", () => { + /** This handle closes and the next one opens and marks it, as a quit, a crash or a chat close + * leaves it; `failMark` makes the mark's write fail. */ + async function reopen( + journal: AgentSessionJournal, + failMark = false + ): Promise { + await journal.close() + const reopened = await open() + if (failMark) { + vi.spyOn(reopened, 'appendQueueReopen').mockRejectedValueOnce(new Error('disk full')) + await expect(reopened.markQueueReopen(0)).rejects.toThrow('disk full') + } else { + await reopened.markQueueReopen(0) + } + return reopened + } + + it('holds the cards queued before the reopen, never one queued after its mark', async () => { + let journal = await open() + await queueDraft(journal, 'before') + expect(reason(journal)).toBeNull() + journal = await reopen(journal) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([ + ['before', true], + ['after', false] + ]) + expect(reason(journal)).toBe('restarted') + }) + + it.each([ + ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'carry-on')], + ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] + ])('%s after the reopen lifts it', async (_name, lift) => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await lift(journal) + expect(reason(journal)).toBeNull() + }) + + it('a turn the agent has not accepted yet lifts nothing', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + await turn(journal, 'carry-on', false) + expect(reason(journal)).toBe('restarted') + }) + + // A hand-off in flight may come back to waiting after the open: the open marks for it too. + it('marks a reopen whose only card is mid-hand-off, and holds it once it comes back', async () => { + let journal = await open() + await queueDraft(journal, 'in-flight') + await journal.appendSubmission( + { + clientMessageId: 'drain-1', + payloadFingerprint: 'fp-in-flight', + body: message('in-flight'), + fence: 0, + handoverRecorded: true + }, + { messageId: 'in-flight', expect: 'waiting', settledByOp: null, hostInstance: HOST } + ) + journal = await reopen(journal) + await journal.resolveDispatch({ + clientMessageId: 'drain-1', + state: 'rejected', + ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { + surface: 'rejection' + }), + fence: 0 + }) + expect(held(journal)).toEqual([['in-flight', true]]) + }) + + // Written after a later send was accepted, the mark starts where the chat stopped, so that + // send still lifts it; it never narrows a wider mark before it. + it('a mark written late starts where the chat stopped, and narrows no earlier mark', async () => { + const journal = await open() + await queueDraft(journal, 'kept') + const stopped = journal.cursor().sequence + 1 + await turn(journal, 'later') + await journal.markQueueReopen(0, stopped) + expect(reason(journal)).toBeNull() + + const reopened = await reopen(journal) + expect(reason(reopened)).toBe('restarted') + await reopened.markQueueReopen(0, stopped) + expect(reason(reopened)).toBe('restarted') + }) + + it('a reopen with no waiting card writes nothing', async () => { + let journal = await open() + await turn(journal, 'earlier') + const before = journal.cursor() + journal = await reopen(journal) + expect(journal.cursor()).toEqual(before) + await queueDraft(journal, 'after') + expect(held(journal)).toEqual([['after', false]]) + }) + + it('a second reopen before any turn still holds them; one after a turn holds them again', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal) + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + // Still waiting (here, nothing drained it): the chat closed with it again, so it waits again. + journal = await reopen(journal) + expect(reason(journal)).toBe('restarted') + }) + + // The open could not write its mark: the open itself is the boundary, so nothing sends by + // itself; the next turn or Resume lifts it, and the next open marks again. + it('without a mark the open itself is where the pause begins', async () => { + let journal = await open() + await queueDraft(journal, 'before') + journal = await reopen(journal, true) + expect(held(journal)).toEqual([['before', true]]) + await turn(journal, 'carry-on') + expect(reason(journal)).toBeNull() + }) + + it("reads an earlier build's stored 'kept' and 'stopped' holds as none; send_failed stays", async () => { + let journal = await open() + for (const id of ['was-kept', 'was-stopped', 'failed']) { + await queueDraft(journal, id) + } + await journal.queuedMessages.hold({ messageIds: ['failed'], reason: 'send_failed' }) + const db = new Database(journalDatabasePath(root)) + for (const [id, stored] of [ + ['was-kept', 'kept'], + ['was-stopped', 'stopped'] + ]) { + db.prepare('UPDATE queued_messages SET hold_reason = ? WHERE message_id = ?').run( + stored, + id + ) + } + db.close() + journal = await reopen(journal) + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.holdReason])).toEqual([ + ['was-kept', null], + ['was-stopped', null], + ['failed', 'send_failed'] + ]) + expect(held(journal)).toEqual([ + ['was-kept', true], + ['was-stopped', true], + ['failed', false] + ]) + }) + }) + + describe('which cards the pauses in force hold', () => { + type Card = Parameters[1] & { messageId: string } + + function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { + const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } + const base = { state: 'waiting', holdReason: null } + return { messageId, ...base, queuedAt, ...fields } + } + + /** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark + * it (0: nothing to mark); no turn, Resume or reopen mark since. */ + function pausesOver(cards: readonly Card[], stopped = 5, opened = 0, clearedIds?: string[]) { + return deriveQueuePauses({ + epoch: 'epoch-1', + marks: { + latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, + resumedSequence: 0, + reopenedSequence: 0, + ...(clearedIds + ? { cleared: { sequence: 4, operationId: 'clear', messageIds: clearedIds } } + : {}) + }, + latestAcceptedTurnSequence: 0, + cards, + reopenFloor: opened ? { epoch: 'epoch-1', sequence: opened + 1 } : null + }) + } + + function holding( + cards: readonly Card[], + stopped?: number, + opened?: number + ): [string, string | null][] { + const pauses = pausesOver(cards, stopped, opened) + return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) + } + + it('a Stop holds a card with no recorded position, and one queued before a rewind', () => { + const cards = [ + card('unrecorded', 5, { queuedAt: null }), + // Later in its own epoch than the Stop is in this one: only the epoch says it came first. + card('before-rewind', 5, { queuedAt: { epoch: 'epoch-0', sequence: 9 } }), + card('after', 5) + ] + expect(holding(cards)).toEqual([ + ['unrecorded', 'stopped'], + ['before-rewind', 'stopped'], + ['after', null] + ]) + }) + + it("a Stop that holds nothing never hides a reopen's: a card from before the open waits", () => { + const after = card('after', 5) + expect(pausesOver([after], 5, 6).map((pause) => pause.reason)).toEqual([ + 'stopped', + 'restarted' + ]) + expect(holding([after], 5, 6)).toEqual([['after', 'restarted']]) + expect(nextSendableQueuedCard(pausesOver([after], 5, 6), [after])).toBeNull() + // Queued since the open, nothing holds it: a card queued after a Stop sends normally. + expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBe(after) + }) + + it("a card names the first pause holding it, and the header names the first held card's", () => { + const cards = [card('before', 3), card('after', 5)] + expect(holding(cards, 5, 6)).toEqual([ + ['before', 'stopped'], + ['after', 'restarted'] + ]) + expect(resumableQueuePause(pausesOver(cards, 5, 6), cards)?.reason).toBe('stopped') + }) + + // R13-1: a message Orca kept after a quit is an ordinary card, in order, under the reopen's pause. + it('a card from before the open waits in order, and so do the cards behind it', () => { + const cards = [card('kept', 1), card('behind', 2)] + expect(holding(cards, 0, 4)).toEqual([ + ['kept', 'restarted'], + ['behind', 'restarted'] + ]) + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() + }) + + // A card held on its own waits for its own Send, so it starts no reopen pause for the others. + it('a card held on its own from before the open starts no reopen pause', () => { + const cards = [card('held', 1, { holdReason: 'send_failed' }), card('live', 5)] + expect(pausesOver(cards, 0, 4)).toEqual([]) + expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)?.messageId).toBe('live') + }) + + it("a /clear's pause that holds nothing never hides a reopen's", () => { + const cards = [card('cleared', 1, { holdReason: 'send_failed' }), card('typed', 2)] + const pauses = pausesOver(cards, 0, 4, ['cleared']) + expect(pauses.map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) + expect( + cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) + ).toEqual([ + ['cleared', null], + ['typed', 'restarted'] + ]) + expect(nextSendableQueuedCard(pauses, cards)).toBeNull() + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts b/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts deleted file mode 100644 index 8064412b49b..00000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-pause.test.ts +++ /dev/null @@ -1,745 +0,0 @@ -// The queue's pause is a pure function of the journal: a Stop and a Resume are rows, an accepted -// turn is a row, and /clear records the exact waiting cards. No separate pause state is stored. - -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 { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import Database from '../../sqlite/sync-database' -import { journalDatabasePath } from './journal-host-database' -import { - createTrackedJournalOpener, - liveTestJournalRows, - updateTestJournalRowJson -} from './journal-host-database-test-support' -import { QueuedMessageNotConsumableError } from './queued-message-consume-error' -import { applyJournalRow, createJournalReducerState } from './journal-reducer' -import { parseJournalRow } from './journal-row-schema' -import type { AgentSessionJournal } from './journal-store' -import { - deriveQueuePauses, - nextSendableQueuedCard, - queuePauseHolding, - resumableQueuePause -} from './queued-message-pause' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-p', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) -} -const HOST = 'proc-1' - -let root: string -let clock = 1_000 -const journals = createTrackedJournalOpener() - -function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -function open(): Promise { - return journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: () => ++clock, - mintEpoch: () => `epoch-${clock}` - }) -} - -function queueDraft(journal: AgentSessionJournal, messageId: string) { - return journal.queuedMessages.insert({ - messageId, - body: message(messageId), - fingerprint: `fp-${messageId}`, - hostInstance: HOST - }) -} - -function clearContext(journal: AgentSessionJournal) { - return journal.context.clear( - { operationId: `clear-${++clock}`, afterFence: 0, clearedAt: clock }, - { write: () => {}, committed: () => {} }, - `caller-clear-${clock}` - ) -} - -/** A turn sent, then accepted by the provider. */ -async function turn(journal: AgentSessionJournal, id: string, accept = true): Promise { - await journal.appendSubmission({ - clientMessageId: id, - payloadFingerprint: `fp-${id}`, - body: message(id), - fence: 0, - handoverRecorded: true - }) - if (accept) { - await acceptTurn(journal, id) - } -} - -function acceptTurn(journal: AgentSessionJournal, id: string) { - return journal.resolveDispatch({ - clientMessageId: id, - state: 'accepted', - providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: `echo-${id}` }, - fence: 0 - }) -} - -/** A person's Stop taking effect. */ -function userStop(journal: AgentSessionJournal) { - return journal.appendStopEvent({ reason: 'user-stop' }, 0) -} - -function reason(journal: AgentSessionJournal): string | null { - return journal.queuedMessages.pauses()[0]?.reason ?? null -} - -/** Each card, and whether a pause in force holds it. */ -function held(journal: AgentSessionJournal): [string, boolean][] { - const pauses = journal.queuedMessages.pauses() - return journal.queuedMessages - .list() - .filter((card) => card.state === 'waiting') - .map((card) => [card.messageId, queuePauseHolding(pauses, card) !== undefined]) -} - -/** The Stop events stored in the live epoch, oldest first. */ -function stopEvents(): unknown[] { - const db = new Database(journalDatabasePath(root), { readonly: true }) - try { - return liveTestJournalRows(db, IDENTITY.sessionId).flatMap((stored) => { - const parsed = parseJournalRow(stored.rowJson) - return parsed.ok && parsed.row.kind === 'tombstone' && parsed.row.stopEvent - ? [parsed.row.stopEvent] - : [] - }) - } finally { - db.close() - } -} - -/** Rewrites one key of each row at a sequence, as a corrupt write would leave it. */ -function rewriteRows(keys: Record): void { - const db = new Database(journalDatabasePath(root)) - try { - for (const stored of liveTestJournalRows(db, IDENTITY.sessionId)) { - const rewrite = keys[stored.seq] - if (rewrite) { - const row: Record = JSON.parse(stored.rowJson) - row[rewrite[0]] = rewrite[1] - updateTestJournalRowJson(db, IDENTITY.sessionId, stored.seq, JSON.stringify(row)) - } - } - } finally { - db.close() - } -} - -/** Tables that could store a pause: none, the journal rows are the only record. */ -function pauseTables(): number { - const db = new Database(journalDatabasePath(root), { readonly: true }) - try { - return db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE '%pause%'") - .all().length - } finally { - db.close() - } -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queue-pause-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe("the queue's pause, derived from the journal", () => { - it("a person's Stop event pauses the queue, even with no card yet, survives reopen, and stores nothing", async () => { - let journal = await open() - await userStop(journal) - expect(reason(journal)).toBe('stopped') - await queueDraft(journal, 'draft-1') - await journal.close() - journal = await open() - expect(reason(journal)).toBe('stopped') - expect(pauseTables()).toBe(0) - }) - - it("any turn sent after the Stop and accepted lifts it, Orca's own mail included", async () => { - const journal = await open() - await turn(journal, 'before-stop', false) - await userStop(journal) - // Sent before the Stop: its acceptance now does not end a Stop that came after it. - await acceptTurn(journal, 'before-stop') - expect(reason(journal)).toBe('stopped') - // Orchestration mail: sent, not yet accepted, lifts nothing; accepted, it lifts the pause. - await turn(journal, 'mail', false) - expect(reason(journal)).toBe('stopped') - await acceptTurn(journal, 'mail') - expect(reason(journal)).toBeNull() - }) - - it('a later Stop is the latest, and a Resume row lifts it', async () => { - const journal = await open() - await userStop(journal) - await turn(journal, 'typed') - expect(reason(journal)).toBeNull() - await userStop(journal) - expect(reason(journal)).toBe('stopped') - await journal.appendQueueResume(0) - expect(reason(journal)).toBeNull() - await userStop(journal) - expect(reason(journal)).toBe('stopped') - expect(pauseTables()).toBe(0) - }) - - it('a card kept by /clear stays paused until Resume in the same journal', async () => { - let journal = await open() - await queueDraft(journal, 'carried') - await clearContext(journal) - expect(reason(journal)).toBe('cleared') - await journal.close() - journal = await open() - expect(reason(journal)).toBe('cleared') - await journal.appendQueueResume(0) - expect(reason(journal)).toBeNull() - expect(pauseTables()).toBe(0) - }) - - it('any accepted turn after /clear lifts its pause, a launch prompt Orca sent included', async () => { - const journal = await open() - await queueDraft(journal, 'carried') - await clearContext(journal) - await turn(journal, 'launch', false) - expect(reason(journal)).toBe('cleared') - await acceptTurn(journal, 'launch') - expect(reason(journal)).toBeNull() - }) - - it('rides a tombstone of an id no item takes, so an older build reads it and changes nothing', async () => { - const journal = await open() - await turn(journal, 'typed') - await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' }, 0) - const db = new Database(journalDatabasePath(root), { readonly: true }) - const stored = liveTestJournalRows(db, IDENTITY.sessionId) - db.close() - const parsed = parseJournalRow(stored.at(-1)?.rowJson ?? '') - expect(parsed).toMatchObject({ - ok: true, - row: { - kind: 'tombstone', - stopEvent: { reason: 'user-stop', turnId: 'turn-1', caller: 'client-1' } - } - }) - if (!parsed.ok || parsed.row.kind !== 'tombstone') { - throw new Error('expected a tombstone row') - } - expect(parsed.row.stopEvent?.at).toBe(parsed.row.ts) - // An older build ignores the unknown key: the row is an ordinary removal of nothing. - const { stopEvent: _ignored, ...asOlderBuildReadsIt } = parsed.row - const state = createJournalReducerState(IDENTITY.sessionId, parsed.row.epoch) - for (const row of stored.slice(0, -1)) { - const earlier = parseJournalRow(row.rowJson) - if (earlier.ok) { - applyJournalRow(state, earlier.row) - } - } - const items = [...state.items.keys()] - const submissions = [...state.submissions.keys()] - applyJournalRow(state, asOlderBuildReadsIt) - expect([...state.items.keys()]).toEqual(items) - expect([...state.submissions.keys()]).toEqual(submissions) - }) - - it('a Stop or Resume row holding a value no build writes is ignored on read, never trusted', async () => { - let journal = await open() - await queueDraft(journal, 'held') - /** Appends a row, then reopens with one of its keys rewritten as a corrupt write would. */ - const corrupted = async ( - append: Promise<{ sequence: number }>, - key: string, - value: unknown - ) => { - const { sequence } = await append - await journal.close() - rewriteRows({ [sequence]: [key, value] }) - journal = await open() - } - const malformed = [null, 'stopped', { at: 1 }] - // Pauses nothing as the latest Stop row. - for (const value of malformed) { - await corrupted(userStop(journal), 'stopEvent', value) - expect(reason(journal)).toBeNull() - } - // Ends nothing after a person's Stop: neither as a later Stop nor as a Resume. - await userStop(journal) - for (const value of malformed) { - await corrupted(userStop(journal), 'stopEvent', value) - expect(reason(journal)).toBe('stopped') - } - await corrupted(journal.appendQueueResume(0), 'queueResume', 'yes') - expect(reason(journal)).toBe('stopped') - expect(held(journal)).toEqual([['held', true]]) - }) - - it("the queue's own consume is refused in its transaction while the pause holds the card; Send-now is not", async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await userStop(journal) - const consume = (id: string, automatic: boolean) => - journal.appendSubmission( - { - clientMessageId: id, - payloadFingerprint: 'fp-draft-1', - body: message('draft-1'), - fence: 0, - handoverRecorded: true - }, - { - messageId: 'draft-1', - expect: 'waiting', - settledByOp: null, - hostInstance: HOST, - ...(automatic ? { yieldsToPause: true as const } : {}) - } - ) - await expect(consume('drain-1', true)).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - expect(journal.submission('drain-1')).toBeUndefined() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - await consume('send-now-1', false) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - }) - - it("a rewind's epoch replacement restates a Stop still pausing, and not one a turn ended", async () => { - const journal = await open() - await userStop(journal) - await journal.replaceEpochItems('handle_forked', 0, []) - expect(reason(journal)).toBe('stopped') - await turn(journal, 'typed') - await journal.replaceEpochItems('handle_forked', 0, []) - expect(reason(journal)).toBeNull() - }) - - it("only a person's Stop pauses: a host's, a close's or an unknown reason holds nothing", async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - for (const reason of ['user-close', 'host-stop', 'evict'] as const) { - await journal.appendStopEvent({ reason }, 0) - } - // A newer build's reason this one does not know. - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: deliberately outside the type, as a newer build could write it. - await journal.appendStopEvent({ reason: 'future-reason' as 'evict' }, 0) - expect(reason(journal)).toBeNull() - }) - - it("a later Stop of any reason ends a person's Stop, without pausing itself", async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - for (const later of ['host-stop', 'evict', 'user-close'] as const) { - await userStop(journal) - expect(held(journal)).toEqual([['draft-1', true]]) - await journal.appendStopEvent({ reason: later }, 0) - expect(reason(journal)).toBeNull() - expect(held(journal)).toEqual([['draft-1', false]]) - } - }) - - it("a person's Stop with no later Stop event is not lifted", async () => { - const journal = await open() - await queueDraft(journal, 'held') - await userStop(journal) - // An eviction writer must write only when it ends a running turn, or it would lift this pause. - expect(reason(journal)).toBe('stopped') - expect(held(journal)).toEqual([['held', true]]) - }) - - it('the restated Stop is the same event: its reason, turn, caller and time', async () => { - const journal = await open() - await journal.appendStopEvent({ reason: 'user-stop', turnId: 'turn-7', caller: 'phone' }, 0) - const stopped = stopEvents() - await journal.replaceEpochItems('handle_forked', 0, []) - expect(stopEvents()).toEqual(stopped) - expect(stopped).toEqual([ - { reason: 'user-stop', turnId: 'turn-7', caller: 'phone', at: expect.any(Number) } - ]) - }) - - it.each([ - ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'typed')], - ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] - ])('a /clear pause %s already lifted stays lifted across a rewind', async (_name, lift) => { - const journal = await open() - await queueDraft(journal, 'carried') - await clearContext(journal) - await lift(journal) - expect(reason(journal)).toBeNull() - await journal.replaceEpochItems('handle_forked', 0, []) - expect(reason(journal)).toBeNull() - }) - - it('a lifted /clear pause and a later Stop are both restated, the Stop still in force', async () => { - const journal = await open() - await queueDraft(journal, 'carried') - await clearContext(journal) - await turn(journal, 'typed') - await userStop(journal) - await journal.replaceEpochItems('handle_forked', 0, []) - expect(journal.queuedMessages.pauses().map((pause) => pause.reason)).toEqual(['stopped']) - await journal.appendQueueResume(0) - expect(reason(journal)).toBeNull() - }) -}) - -describe('which cards a pause holds', () => { - it('only cards queued before the Stop event: one queued after it is a new instruction', async () => { - let journal = await open() - await queueDraft(journal, 'before') - await userStop(journal) - await queueDraft(journal, 'after') - expect(held(journal)).toEqual([ - ['before', true], - ['after', false] - ]) - await journal.close() - journal = await open() - expect(held(journal)).toEqual([ - ['before', true], - ['after', false] - ]) - }) - - it('a steer the Stop withdrew comes back in its own place, and is held', async () => { - const journal = await open() - await queueDraft(journal, 'steered') - await journal.appendSubmission( - { - clientMessageId: 'send-now-1', - payloadFingerprint: 'fp-steered', - body: message('steered'), - fence: 0, - handoverRecorded: true - }, - { messageId: 'steered', expect: 'waiting', settledByOp: null, hostInstance: HOST } - ) - await userStop(journal) - await queueDraft(journal, 'after') - await journal.resolveDispatch({ - clientMessageId: 'send-now-1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('cancelled'), { surface: 'rejection' }), - fence: 0 - }) - expect(held(journal)).toEqual([ - ['steered', true], - ['after', false] - ]) - }) - - it("the queue's own consume refuses a newer card while an older one is held: nothing overtakes", async () => { - const journal = await open() - await queueDraft(journal, 'held') - await userStop(journal) - await queueDraft(journal, 'newer') - await expect( - journal.appendSubmission( - { - clientMessageId: 'drain-newer', - payloadFingerprint: 'fp-newer', - body: message('newer'), - fence: 0, - handoverRecorded: true - }, - { - messageId: 'newer', - expect: 'waiting', - settledByOp: null, - hostInstance: HOST, - yieldsToPause: true - } - ) - ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - expect(journal.queuedMessages.get('newer')?.state).toBe('waiting') - }) - - it("'cleared' holds the cards already queued, not one typed after it", async () => { - const journal = await open() - await queueDraft(journal, 'carried-1') - await queueDraft(journal, 'carried-2') - await clearContext(journal) - await queueDraft(journal, 'typed-here') - expect(held(journal)).toEqual([ - ['carried-1', true], - ['carried-2', true], - ['typed-here', false] - ]) - }) -}) - -describe("a reopen's pause", () => { - /** This handle closes and the next one opens and marks it, as a quit, a crash or a chat close - * leaves it; `failMark` makes the mark's write fail. */ - async function reopen( - journal: AgentSessionJournal, - failMark = false - ): Promise { - await journal.close() - const reopened = await open() - if (failMark) { - vi.spyOn(reopened, 'appendQueueReopen').mockRejectedValueOnce(new Error('disk full')) - await expect(reopened.markQueueReopen(0)).rejects.toThrow('disk full') - } else { - await reopened.markQueueReopen(0) - } - return reopened - } - - it('holds the cards queued before the reopen, never one queued after its mark', async () => { - let journal = await open() - await queueDraft(journal, 'before') - expect(reason(journal)).toBeNull() - journal = await reopen(journal) - await queueDraft(journal, 'after') - expect(held(journal)).toEqual([ - ['before', true], - ['after', false] - ]) - expect(reason(journal)).toBe('restarted') - }) - - it.each([ - ['an accepted turn', (journal: AgentSessionJournal) => turn(journal, 'carry-on')], - ['a Resume', (journal: AgentSessionJournal) => journal.appendQueueResume(0)] - ])('%s after the reopen lifts it', async (_name, lift) => { - let journal = await open() - await queueDraft(journal, 'before') - journal = await reopen(journal) - await lift(journal) - expect(reason(journal)).toBeNull() - }) - - it('a turn the agent has not accepted yet lifts nothing', async () => { - let journal = await open() - await queueDraft(journal, 'before') - journal = await reopen(journal) - await turn(journal, 'carry-on', false) - expect(reason(journal)).toBe('restarted') - }) - - // A hand-off in flight may come back to waiting after the open: the open marks for it too. - it('marks a reopen whose only card is mid-hand-off, and holds it once it comes back', async () => { - let journal = await open() - await queueDraft(journal, 'in-flight') - await journal.appendSubmission( - { - clientMessageId: 'drain-1', - payloadFingerprint: 'fp-in-flight', - body: message('in-flight'), - fence: 0, - handoverRecorded: true - }, - { messageId: 'in-flight', expect: 'waiting', settledByOp: null, hostInstance: HOST } - ) - journal = await reopen(journal) - await journal.resolveDispatch({ - clientMessageId: 'drain-1', - state: 'rejected', - ...agentSessionFailureWords(agentSessionFailureFact('notDelivered'), { - surface: 'rejection' - }), - fence: 0 - }) - expect(held(journal)).toEqual([['in-flight', true]]) - }) - - // Written after a later send was accepted, the mark starts where the chat stopped, so that - // send still lifts it; it never narrows a wider mark before it. - it('a mark written late starts where the chat stopped, and narrows no earlier mark', async () => { - const journal = await open() - await queueDraft(journal, 'kept') - const stopped = journal.cursor().sequence + 1 - await turn(journal, 'later') - await journal.markQueueReopen(0, stopped) - expect(reason(journal)).toBeNull() - - const reopened = await reopen(journal) - expect(reason(reopened)).toBe('restarted') - await reopened.markQueueReopen(0, stopped) - expect(reason(reopened)).toBe('restarted') - }) - - it('a reopen with no waiting card writes nothing', async () => { - let journal = await open() - await turn(journal, 'earlier') - const before = journal.cursor() - journal = await reopen(journal) - expect(journal.cursor()).toEqual(before) - await queueDraft(journal, 'after') - expect(held(journal)).toEqual([['after', false]]) - }) - - it('a second reopen before any turn still holds them; one after a turn holds them again', async () => { - let journal = await open() - await queueDraft(journal, 'before') - journal = await reopen(journal) - journal = await reopen(journal) - expect(reason(journal)).toBe('restarted') - await turn(journal, 'carry-on') - expect(reason(journal)).toBeNull() - // Still waiting (here, nothing drained it): the chat closed with it again, so it waits again. - journal = await reopen(journal) - expect(reason(journal)).toBe('restarted') - }) - - // The open could not write its mark: the open itself is the boundary, so nothing sends by - // itself; the next turn or Resume lifts it, and the next open marks again. - it('without a mark the open itself is where the pause begins', async () => { - let journal = await open() - await queueDraft(journal, 'before') - journal = await reopen(journal, true) - expect(held(journal)).toEqual([['before', true]]) - await turn(journal, 'carry-on') - expect(reason(journal)).toBeNull() - }) - - it("reads an earlier build's stored 'kept' and 'stopped' holds as none; send_failed stays", async () => { - let journal = await open() - for (const id of ['was-kept', 'was-stopped', 'failed']) { - await queueDraft(journal, id) - } - await journal.queuedMessages.hold({ messageIds: ['failed'], reason: 'send_failed' }) - const db = new Database(journalDatabasePath(root)) - for (const [id, stored] of [ - ['was-kept', 'kept'], - ['was-stopped', 'stopped'] - ]) { - db.prepare('UPDATE queued_messages SET hold_reason = ? WHERE message_id = ?').run(stored, id) - } - db.close() - journal = await reopen(journal) - expect(journal.queuedMessages.list().map((row) => [row.messageId, row.holdReason])).toEqual([ - ['was-kept', null], - ['was-stopped', null], - ['failed', 'send_failed'] - ]) - expect(held(journal)).toEqual([ - ['was-kept', true], - ['was-stopped', true], - ['failed', false] - ]) - }) -}) - -describe('which cards the pauses in force hold', () => { - type Card = Parameters[1] & { messageId: string } - - function card(messageId: string, queuedAfter: number, fields: Partial = {}): Card { - const queuedAt = { epoch: 'epoch-1', sequence: queuedAfter + 1 } - const base = { state: 'waiting', holdReason: null } - return { messageId, ...base, queuedAt, ...fields } - } - - /** A Stop at sequence 5 unless `stopped` is 0; this handle opened at `opened` and could not mark - * it (0: nothing to mark); no turn, Resume or reopen mark since. */ - function pausesOver(cards: readonly Card[], stopped = 5, opened = 0, clearedIds?: string[]) { - return deriveQueuePauses({ - epoch: 'epoch-1', - marks: { - latestStop: stopped ? { sequence: stopped, event: { reason: 'user-stop', at: 0 } } : null, - resumedSequence: 0, - reopenedSequence: 0, - ...(clearedIds - ? { cleared: { sequence: 4, operationId: 'clear', messageIds: clearedIds } } - : {}) - }, - latestAcceptedTurnSequence: 0, - cards, - reopenFloor: opened ? { epoch: 'epoch-1', sequence: opened + 1 } : null - }) - } - - function holding( - cards: readonly Card[], - stopped?: number, - opened?: number - ): [string, string | null][] { - const pauses = pausesOver(cards, stopped, opened) - return cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) - } - - it('a Stop holds a card with no recorded position, and one queued before a rewind', () => { - const cards = [ - card('unrecorded', 5, { queuedAt: null }), - // Later in its own epoch than the Stop is in this one: only the epoch says it came first. - card('before-rewind', 5, { queuedAt: { epoch: 'epoch-0', sequence: 9 } }), - card('after', 5) - ] - expect(holding(cards)).toEqual([ - ['unrecorded', 'stopped'], - ['before-rewind', 'stopped'], - ['after', null] - ]) - }) - - it("a Stop that holds nothing never hides a reopen's: a card from before the open waits", () => { - const after = card('after', 5) - expect(pausesOver([after], 5, 6).map((pause) => pause.reason)).toEqual(['stopped', 'restarted']) - expect(holding([after], 5, 6)).toEqual([['after', 'restarted']]) - expect(nextSendableQueuedCard(pausesOver([after], 5, 6), [after])).toBeNull() - // Queued since the open, nothing holds it: a card queued after a Stop sends normally. - expect(nextSendableQueuedCard(pausesOver([after]), [after])).toBe(after) - }) - - it("a card names the first pause holding it, and the header names the first held card's", () => { - const cards = [card('before', 3), card('after', 5)] - expect(holding(cards, 5, 6)).toEqual([ - ['before', 'stopped'], - ['after', 'restarted'] - ]) - expect(resumableQueuePause(pausesOver(cards, 5, 6), cards)?.reason).toBe('stopped') - }) - - // R13-1: a message Orca kept after a quit is an ordinary card, in order, under the reopen's pause. - it('a card from before the open waits in order, and so do the cards behind it', () => { - const cards = [card('kept', 1), card('behind', 2)] - expect(holding(cards, 0, 4)).toEqual([ - ['kept', 'restarted'], - ['behind', 'restarted'] - ]) - expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)).toBeNull() - }) - - // A card held on its own waits for its own Send, so it starts no reopen pause for the others. - it('a card held on its own from before the open starts no reopen pause', () => { - const cards = [card('held', 1, { holdReason: 'send_failed' }), card('live', 5)] - expect(pausesOver(cards, 0, 4)).toEqual([]) - expect(nextSendableQueuedCard(pausesOver(cards, 0, 4), cards)?.messageId).toBe('live') - }) - - it("a /clear's pause that holds nothing never hides a reopen's", () => { - const cards = [card('cleared', 1, { holdReason: 'send_failed' }), card('typed', 2)] - const pauses = pausesOver(cards, 0, 4, ['cleared']) - expect(pauses.map((pause) => pause.reason)).toEqual(['cleared', 'restarted']) - expect( - cards.map((each) => [each.messageId, queuePauseHolding(pauses, each)?.reason ?? null]) - ).toEqual([ - ['cleared', null], - ['typed', 'restarted'] - ]) - expect(nextSendableQueuedCard(pauses, cards)).toBeNull() - }) -}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-store.spec.ts new file mode 100644 index 00000000000..5da3dec9515 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-store.spec.ts @@ -0,0 +1,838 @@ +// The draft store's contract: exactly-once consume in one transaction, the +// rejected-draft settlement following the journal's EFFECTIVE settlement, retention +// that never outruns a slow refusal, and rows that survive epoch replacement. + +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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' +import type { + AgentJournalMessageItem, + AgentSessionJournalIdentity +} from '../../../shared/agent-session-journal-types' +import { agentSessionFailureFact } from '../../../shared/agent-session-failure' +import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' +import Database from '../../sqlite/sync-database' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { journalDatabasePath } from './journal-host-database' +import { JournalQueuedMessages, QUEUED_MESSAGE_REPLAY_WINDOW_MS } from './journal-queued-messages' +import { QueuedMessageNotConsumableError } from './queued-message-consume-error' +import type { AgentSessionJournal } from './journal-store' +import type { AgentMessageSource } from '../../../shared/agent-session-message-source' +import { + closeTestJournalHostDatabases, + createTrackedJournalOpener +} from './journal-host-database-test-support' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' + +export function registerQueuedMessageStoreTests(): void { + const IDENTITY: AgentSessionJournalIdentity = { + sessionId: 'session-q', + workspaceId: 'ws-1', + hostId: 'host-1', + agent: 'claude', + providerHandle: claudeProviderHandle('native-1', null) + } + + let root: string + let clock = 1_000 + + function tick(): number { + clock += 1 + return clock + } + + function message(text: string): AgentJournalMessageItem { + return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } + } + + const journals = createTrackedJournalOpener() + const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { + surface: 'rejection' + }) + const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { + surface: 'rejection' + }) + const PROVIDER_REFUSAL = agentSessionFailureFact('providerRejected', { + detail: { text: 'Claude refused this payload', audience: 'person' } + }) + /** A provider refusal as a settled rejection stores it: its sentence and typed fact. */ + function refusal(text: string) { + return agentSessionFailureWords( + agentSessionFailureFact('providerRejected', { detail: { text, audience: 'person' } }), + { surface: 'rejection' } + ) + } + + async function open(): Promise { + const journal = await journals.open({ + identity: IDENTITY, + stateDirectory: root, + now: tick, + mintEpoch: () => `epoch-${clock}` + }) + return journal + } + + async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { + return journal.queuedMessages.insert({ + messageId, + body: message(text), + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) + } + + async function consumeDraft( + journal: AgentSessionJournal, + messageId: string, + options: { + as?: string + expect?: 'waiting' | 'returned' + settledByOp?: string | null + origin?: 'client' | 'host' + } = {} + ) { + const draft = journal.queuedMessages.get(messageId) + await journal.appendSubmission( + { + clientMessageId: options.as ?? `sub-${messageId}`, + payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`, + body: draft?.body ?? message('queued text'), + fence: 0, + handoverRecorded: true, + ...(options.origin ? { origin: options.origin } : {}) + }, + { + messageId, + expect: options.expect ?? 'waiting', + settledByOp: options.settledByOp ?? null + } + ) + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'orca-queued-message-')) + clock = 1_000 + }) + + afterEach(async () => { + await journals.closeAll() + await rm(root, { recursive: true, force: true }) + }) + + describe('draft rows', () => { + it('creates the table at open without bumping user_version, so an old build stays writable', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.close() + const db = new Database(journalDatabasePath(root), { readonly: true }) + try { + const version = Number(db.pragma('user_version', { simple: true })) + // An old build compares stored == supported and keeps writing; a bump + // would cost it every chat after a downgrade. + expect(version).toBe(JOURNAL_DB_SCHEMA_VERSION) + const table = db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('queued_messages') + expect(table).toBeDefined() + } finally { + db.close() + } + }) + + it('opens a database an older build shaped (no drafts table) and creates the table', async () => { + const first = await open() + await first.appendItem( + { provider: 'orca', clientMessageId: 'seed' }, + { kind: 'status', text: 'seed' }, + { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await first.close() + // The table is created when the host opens its database, so the host restarts around the edit. + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + db.exec('DROP TABLE queued_messages') + db.close() + const journal = await open() + const row = await queueDraft(journal, 'draft-1') + expect(row.position).toBe(1) + }) + + it('assigns monotonic positions and lists in order', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await queueDraft(journal, 'draft-2') + const listed = journal.queuedMessages.list() + expect(listed.map((row) => [row.messageId, row.position, row.state])).toEqual([ + ['draft-1', 1, 'waiting'], + ['draft-2', 2, 'waiting'] + ]) + }) + + it('replays an insert under an already-used id instead of duplicating', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const again = await queueDraft(journal, 'draft-1') + expect(again.position).toBe(1) + expect(journal.queuedMessages.list()).toHaveLength(1) + }) + + it("keeps an agent card's body as written across reopen: the queue reads nothing of its sender", async () => { + const from: AgentMessageSource = { + kind: 'agent', + senders: [ + { + party: { + address: 'structworker_1', + terminalHandle: 'structworker_1', + orcaSessionId: null + }, + name: 'Reviewer' + } + ], + orchestration: { + message: 'mail-notice', + mailbox: 'run:r1', + dispatchId: 'd1', + messages: [{ messageId: 'm1', runId: 'r1', from: 'structworker_1' }] + } + } + const first = await open() + const insert = (messageId: string, body: AgentJournalMessageItem) => + first.queuedMessages.insert({ + messageId, + body, + fingerprint: `fp-${messageId}`, + hostInstance: 'proc-1' + }) + await insert('agent-card', { ...message('You have 1 orchestration message.'), from }) + await insert('newer-kind', { + ...message('a task'), + from: { ...from, orchestration: null } + }) + await insert('malformed', message('typed')) + await first.close() + closeTestJournalHostDatabases() + const db = new Database(journalDatabasePath(root)) + // A newer build's message kind, and a value no build writes: carried as written, for clients to read. + const setFrom = db.prepare( + "UPDATE queued_messages SET body_json = json_set(body_json, '$.from', json(?)) WHERE message_id = ?" + ) + setFrom.run( + JSON.stringify({ ...from, orchestration: { message: 'task', taskId: 't1' } }), + 'newer-kind' + ) + setFrom.run(JSON.stringify('nobody'), 'malformed') + // A table from a build that also kept the sender in a column of its own. + db.exec('ALTER TABLE queued_messages ADD COLUMN source_json TEXT') + db.close() + const reopened = await open() + expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.body.from])).toEqual([ + ['agent-card', from], + ['newer-kind', { ...from, orchestration: { message: 'task', taskId: 't1' } }], + ['malformed', 'nobody'] + ]) + // That older table still takes new cards. + await queueDraft(reopened, 'after') + expect(reopened.queuedMessages.list()).toHaveLength(4) + }) + + it('drafts survive epoch replacement, which deletes only journal rows', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.replaceEpochItems('handle_forked', 0, []) + expect(journal.queuedMessages.list().map((row) => row.messageId)).toEqual(['draft-1']) + }) + }) + + describe('consume', () => { + it('converts waiting → dispatched and appends the submission in one transaction', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('dispatched') + expect(row?.consumedAs).toBe('sub-draft-1') + expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) + }) + + it('never hands a draft off under its own id: the submission names it by link, not id equality', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await expect(consumeDraft(journal, 'draft-1', { as: 'draft-1' })).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('never records a second submission under an id it already holds, whatever state it settled in', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + const cursor = journal.cursor() + await expect( + journal.appendSubmission({ + clientMessageId: 'sub-draft-1', + payloadFingerprint: 'fp-draft-1', + body: message('queued text'), + fence: 0, + handoverRecorded: true + }) + ).rejects.toMatchObject({ code: 'journal_submission_exists' }) + // The refusal stands: re-appending would reset it to pending and hand it over again. + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + expect(journal.cursor()).toEqual(cursor) + }) + + it('a second consume of the same draft fails and appends nothing (exactly-once)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await expect(consumeDraft(journal, 'draft-1', { as: 'second-id' })).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) + }) + + it('a consume racing a withdraw loses and appends nothing', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller\u0000op-1' + }) + await expect(consumeDraft(journal, 'draft-1')).rejects.toBeInstanceOf( + QueuedMessageNotConsumableError + ) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('a consume carrying text the card no longer holds loses: an edit since the read never sends the old text', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1', 'old') + const read = journal.queuedMessages.get('draft-1')! + await journal.queuedMessages.update({ + messageId: 'draft-1', + expectedBodyFingerprint: read.fingerprint, + text: 'new' + }) + await expect( + journal.appendSubmission( + { + clientMessageId: 'sub-draft-1', + payloadFingerprint: read.fingerprint, + body: read.body, + fence: 0, + handoverRecorded: true + }, + { messageId: 'draft-1', expect: 'waiting', settledByOp: null } + ) + ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) + expect(journal.submissions()).toHaveLength(0) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + + it('a failed submission insert rolls the draft transition back', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + const cursor = journal.cursor() + // Occupy the next sequence directly so the append's INSERT violates the + // primary key inside the transaction, after the draft was transitioned. + const db = new Database(journalDatabasePath(root)) + db.prepare( + 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' + ).run(IDENTITY.sessionId, cursor.epoch, cursor.sequence + 1, tick(), '{}') + db.close() + await expect(consumeDraft(journal, 'draft-1')).rejects.toThrow() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') + }) + }) + + describe('returned transition (D1/N4)', () => { + it('a non-withdrawn rejection returns the consumed draft with its reason', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('Claude refused this payload'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('Claude refused this payload').reason) + expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) + }) + + it('a late rejection row after acceptance settles nothing and returns no card', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('late duplicate'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + }) + + it("a Stop's withdrawal before the agent received it sends the draft back to waiting, held like the rest, and it survives a restart", async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + // The Stop's own withdrawal path: the queued (not handed over) submission. + expect(await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)).toEqual(['sub-draft-1']) + // Nothing failed: no refusal to show, its position kept, its spent id recorded. + const requeued = { + state: 'waiting', + position: 1, + holdReason: null, + consumedAs: null, + returnedReason: null, + returnedRejection: null + } + expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) + // Atomic with the rejection row: a crash before the Stop answered keeps the text. + await journal.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) + }) + + it('a draft sent back to waiting hands off again under a fresh id, never a spent one', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + // The spent id already names a rejected submission: one id, one delivery. + await expect(consumeDraft(journal, 'draft-1')).rejects.toMatchObject({ + code: 'journal_submission_exists' + }) + expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') + await consumeDraft(journal, 'draft-1', { as: 'fresh-1' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + consumedAs: 'fresh-1' + }) + // Both hand-offs name the draft. + expect(journal.submission('fresh-1')).toMatchObject({ + dispatchState: 'pending', + queuedMessageId: 'draft-1' + }) + expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1') + }) + + it.each([ + { by: 'the queue', origin: 'host' as const }, + { by: 'the person', origin: 'client' as const } + ])( + 'a restart between $by’s consume and handover sends the draft back to waiting', + async ({ origin }) => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1', { origin }) + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => + journal.wroteBeforeOpen(submission.acceptedSequence) + ) + // Whoever sent it, it waits with no hold of its own, under the reopen's pause. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + hostInstance: 'proc-1', + consumedAs: null, + returnedReason: null + }) + } + ) + + it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('first refusal'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('returned') + // Send on the returned card re-consumes under a fresh submission id. + await consumeDraft(journal, 'draft-1', { as: 'resend-1', expect: 'returned' }) + // The earlier refusal retires with the card: the row now describes the re-send. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + consumedAs: 'resend-1', + returnedReason: null, + returnedRejection: null + }) + // A duplicate resolution of the FIRST submission is ignored by the journal + // and must not alter the draft's current relation. + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('duplicate of first refusal'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + // The re-send's own refusal returns the card, matched via consumed_as. + await journal.resolveDispatch({ + clientMessageId: 'resend-1', + state: 'rejected', + ...refusal('second refusal'), + fence: 0 + }) + const returned = journal.queuedMessages.get('draft-1') + expect(returned?.state).toBe('returned') + expect(returned?.returnedReason).toBe(refusal('second refusal').reason) + // The first refusal's fact does not outlive it: the pair is the second submission's. + expect(returned?.returnedRejection).toEqual(refusal('second refusal').rejection) + expect(returned?.returnedRejection).not.toEqual(PROVIDER_REFUSAL) + }) + + it('a rejection never revives a withdrawn draft', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('again'), + fence: 0 + }) + expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') + }) + + it('the returned row and its stored reason survive epoch replacement and reopen (B1)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('stored refusal'), + fence: 0 + }) + await journal.replaceEpochItems('handle_forked', 0, []) + await journal.close() + journal = await open() + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('stored refusal').reason) + }) + }) + + describe('withdraw', () => { + it('withdraws waiting and returned rows together into op-stamped receipts', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1', 'first text') + await queueDraft(journal, 'draft-2', 'second text') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + const withdrawn = await journal.queuedMessages.withdraw({ + messageIds: ['draft-1', 'draft-2'], + settledByOp: 'caller\u0000stop-1' + }) + expect(withdrawn.map((row) => [row.messageId, row.body.blocks])).toEqual([ + ['draft-1', [{ type: 'text', text: 'first text' }]], + ['draft-2', [{ type: 'text', text: 'second text' }]] + ]) + // A lost acknowledgement replays from the tombstones, keyed by the + // caller-scoped operation key — never from the ledger. + const receipts = journal.queuedMessages.receipts('caller\u0000stop-1') + expect(receipts.map((row) => row.messageId)).toEqual(['draft-1', 'draft-2']) + // Pending or dispatched rows stay outside the withdrawable set. + const second = await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller\u0000stop-2' + }) + expect(second).toHaveLength(0) + }) + + it('two callers reusing one operation id read only their own receipts', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.withdraw({ + messageIds: ['draft-1'], + settledByOp: 'caller-a\u0000op-1' + }) + expect(journal.queuedMessages.receipts('caller-b\u0000op-1')).toHaveLength(0) + expect(journal.queuedMessages.receipts('caller-a\u0000op-1')).toHaveLength(1) + }) + }) + + describe('open-time repair and retention', () => { + it('a failed repair is reported and skipped, never failing the open', async () => { + const repair = vi + .spyOn(JournalQueuedMessages.prototype, 'repairAndPrune') + .mockRejectedValueOnce(new Error('SQLITE_FULL')) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + try { + const journal = await open() + expect(warn).toHaveBeenCalledWith( + '[journal-open] queued-message repair skipped:', + expect.objectContaining({ error: 'SQLITE_FULL' }) + ) + await queueDraft(journal, 'draft-1') + expect(journal.queuedMessages.list()).toHaveLength(1) + } finally { + repair.mockRestore() + warn.mockRestore() + } + }) + + // The repair reaches the live hook's answer from the stored rejection. + it.each([ + { origin: 'client' as const, cause: 'hostRestarted' as const }, + { origin: 'host' as const, cause: 'hostRestarted' as const }, + { origin: 'client' as const, cause: 'chatClosed' as const }, + { origin: 'host' as const, cause: 'chatClosed' as const } + ])( + 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, back to waiting', + async ({ origin, cause }) => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1', { origin }) + await journal.rejectQueuedSubmissions( + 0, + agentSessionFailureWords(agentSessionFailureFact(cause), { surface: 'rejection' }) + ) + await journal.close() + // The hook "was skipped": the draft is back to dispatched behind the stored rejection. + const db = new Database(journalDatabasePath(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" + ).run('draft-1') + db.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + consumedAs: null + }) + } + ) + + it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused while downgraded'), + rejection: PROVIDER_REFUSAL, + fence: 0 + }) + await journal.close() + // Simulate the old build having written the rejection with no hook: put the + // draft back to dispatched behind the stored fact. + const db = new Database(journalDatabasePath(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', returned_reason = NULL, returned_rejection = NULL WHERE message_id = ?" + ).run('draft-1') + db.close() + journal = await open() + const row = journal.queuedMessages.get('draft-1') + expect(row?.state).toBe('returned') + expect(row?.returnedReason).toBe(refusal('refused while downgraded').reason) + expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) + }) + + it('sends back to waiting a dispatched row whose submission a Stop withdrew with no hook, never leaving it dispatched', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) + await journal.close() + const db = new Database(journalDatabasePath(root)) + db.prepare( + "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" + ).run('draft-1') + db.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + // The same settlement the live hook applies. + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + holdReason: null, + consumedAs: null, + returnedReason: null + }) + }) + + it('keeps a dispatched row while its submission is still pending, even past the window, so a late rejection still settles it (N5)', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.close() + // Reopen "25 hours" later: the submission is still queued/pending. + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 60 * 60 * 1000 + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') + // The delivery loop's leftover rejection now sends it back to waiting. + await journal.rejectQueuedSubmissions(0, HOST_RESTARTED) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'waiting', + consumedAs: null + }) + }) + + it('prunes accepted and withdrawn rows once the replay window passes, and never waiting or returned rows', async () => { + let journal = await open() + await queueDraft(journal, 'accepted-1') + await consumeDraft(journal, 'accepted-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-accepted-1', + state: 'accepted', + providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, + fence: 0 + }) + await queueDraft(journal, 'withdrawn-1') + await journal.queuedMessages.withdraw({ + messageIds: ['withdrawn-1'], + settledByOp: 'c\u0000op-w' + }) + await queueDraft(journal, 'waiting-1') + await queueDraft(journal, 'returned-1') + await consumeDraft(journal, 'returned-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-returned-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.close() + clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 + journal = await open() + expect(journal.queuedMessages.list().map((row) => [row.messageId, row.state])).toEqual([ + ['waiting-1', 'waiting'], + ['returned-1', 'returned'] + ]) + }) + + it('keeps fresh tombstones inside the replay window', async () => { + let journal = await open() + await queueDraft(journal, 'withdrawn-1') + await journal.queuedMessages.withdraw({ + messageIds: ['withdrawn-1'], + settledByOp: 'c\u0000op-w' + }) + await journal.close() + clock += 1_000 + journal = await open() + expect(journal.queuedMessages.get('withdrawn-1')?.state).toBe('withdrawn') + }) + }) + + describe('holds', () => { + it('a hold is stored on the row, survives reopen, and withdraw clears it', async () => { + let journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') + await journal.close() + journal = await open() + expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'withdrawn', + holdReason: null + }) + }) + + it('consume clears the hold in the same transaction (Send-now overrides it)', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + await consumeDraft(journal, 'draft-1') + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'dispatched', + holdReason: null + }) + }) + + it('a withdraw naming no drafts touches nothing — a Delete race with no rows costs no write', async () => { + const journal = await open() + await journal.close() + await expect( + journal.queuedMessages.withdraw({ messageIds: [], settledByOp: 'c\u0000op' }) + ).resolves.toEqual([]) + }) + + it('holds reach only waiting rows', async () => { + const journal = await open() + await queueDraft(journal, 'draft-1') + await consumeDraft(journal, 'draft-1') + await journal.resolveDispatch({ + clientMessageId: 'sub-draft-1', + state: 'rejected', + ...refusal('refused'), + fence: 0 + }) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(journal.queuedMessages.get('draft-1')).toMatchObject({ + state: 'returned', + holdReason: null + }) + }) + }) + + describe('the commit listener', () => { + it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => { + const journal = await open() + let commits = 0 + journal.observeCommits(() => { + commits += 1 + }) + await queueDraft(journal, 'draft-1') + expect(commits).toBe(1) + // An idempotent replay changes nothing and stays silent. + await queueDraft(journal, 'draft-1') + expect(commits).toBe(1) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(commits).toBe(2) + await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) + expect(commits).toBe(2) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) + expect(commits).toBe(3) + await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op-2' }) + expect(commits).toBe(3) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts b/src/main/native-chat/agent-session-journal/queued-message-store.test.ts deleted file mode 100644 index 991b384b640..00000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-store.test.ts +++ /dev/null @@ -1,836 +0,0 @@ -// The draft store's contract: exactly-once consume in one transaction, the -// rejected-draft settlement following the journal's EFFECTIVE settlement, retention -// that never outruns a slow refusal, and rows that survive epoch replacement. - -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 { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' -import type { - AgentJournalMessageItem, - AgentSessionJournalIdentity -} from '../../../shared/agent-session-journal-types' -import { agentSessionFailureFact } from '../../../shared/agent-session-failure' -import { agentSessionFailureWords } from '../../../shared/agent-session-failure-words' -import Database from '../../sqlite/sync-database' -import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' -import { journalDatabasePath } from './journal-host-database' -import { JournalQueuedMessages, QUEUED_MESSAGE_REPLAY_WINDOW_MS } from './journal-queued-messages' -import { QueuedMessageNotConsumableError } from './queued-message-consume-error' -import type { AgentSessionJournal } from './journal-store' -import type { AgentMessageSource } from '../../../shared/agent-session-message-source' -import { - closeTestJournalHostDatabases, - createTrackedJournalOpener -} from './journal-host-database-test-support' -import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' - -const IDENTITY: AgentSessionJournalIdentity = { - sessionId: 'session-q', - workspaceId: 'ws-1', - hostId: 'host-1', - agent: 'claude', - providerHandle: claudeProviderHandle('native-1', null) -} - -let root: string -let clock = 1_000 - -function tick(): number { - clock += 1 - return clock -} - -function message(text: string): AgentJournalMessageItem { - return { kind: 'message', role: 'user', blocks: [{ type: 'text', text }] } -} - -const journals = createTrackedJournalOpener() -const STOP_WITHDRAWAL = agentSessionFailureWords(agentSessionFailureFact('cancelled'), { - surface: 'rejection' -}) -const HOST_RESTARTED = agentSessionFailureWords(agentSessionFailureFact('hostRestarted'), { - surface: 'rejection' -}) -const PROVIDER_REFUSAL = agentSessionFailureFact('providerRejected', { - detail: { text: 'Claude refused this payload', audience: 'person' } -}) -/** A provider refusal as a settled rejection stores it: its sentence and typed fact. */ -function refusal(text: string) { - return agentSessionFailureWords( - agentSessionFailureFact('providerRejected', { detail: { text, audience: 'person' } }), - { surface: 'rejection' } - ) -} - -async function open(): Promise { - const journal = await journals.open({ - identity: IDENTITY, - stateDirectory: root, - now: tick, - mintEpoch: () => `epoch-${clock}` - }) - return journal -} - -async function queueDraft(journal: AgentSessionJournal, messageId: string, text = 'queued text') { - return journal.queuedMessages.insert({ - messageId, - body: message(text), - fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' - }) -} - -async function consumeDraft( - journal: AgentSessionJournal, - messageId: string, - options: { - as?: string - expect?: 'waiting' | 'returned' - settledByOp?: string | null - origin?: 'client' | 'host' - } = {} -) { - const draft = journal.queuedMessages.get(messageId) - await journal.appendSubmission( - { - clientMessageId: options.as ?? `sub-${messageId}`, - payloadFingerprint: draft?.fingerprint ?? `fp-${messageId}`, - body: draft?.body ?? message('queued text'), - fence: 0, - handoverRecorded: true, - ...(options.origin ? { origin: options.origin } : {}) - }, - { - messageId, - expect: options.expect ?? 'waiting', - settledByOp: options.settledByOp ?? null - } - ) -} - -beforeEach(async () => { - root = await mkdtemp(join(tmpdir(), 'orca-queued-message-')) - clock = 1_000 -}) - -afterEach(async () => { - await journals.closeAll() - await rm(root, { recursive: true, force: true }) -}) - -describe('draft rows', () => { - it('creates the table at open without bumping user_version, so an old build stays writable', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.close() - const db = new Database(journalDatabasePath(root), { readonly: true }) - try { - const version = Number(db.pragma('user_version', { simple: true })) - // An old build compares stored == supported and keeps writing; a bump - // would cost it every chat after a downgrade. - expect(version).toBe(JOURNAL_DB_SCHEMA_VERSION) - const table = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") - .get('queued_messages') - expect(table).toBeDefined() - } finally { - db.close() - } - }) - - it('opens a database an older build shaped (no drafts table) and creates the table', async () => { - const first = await open() - await first.appendItem( - { provider: 'orca', clientMessageId: 'seed' }, - { kind: 'status', text: 'seed' }, - { fence: 0, turnScope: AGENT_JOURNAL_THREAD_SCOPE } - ) - await first.close() - // The table is created when the host opens its database, so the host restarts around the edit. - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - db.exec('DROP TABLE queued_messages') - db.close() - const journal = await open() - const row = await queueDraft(journal, 'draft-1') - expect(row.position).toBe(1) - }) - - it('assigns monotonic positions and lists in order', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await queueDraft(journal, 'draft-2') - const listed = journal.queuedMessages.list() - expect(listed.map((row) => [row.messageId, row.position, row.state])).toEqual([ - ['draft-1', 1, 'waiting'], - ['draft-2', 2, 'waiting'] - ]) - }) - - it('replays an insert under an already-used id instead of duplicating', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - const again = await queueDraft(journal, 'draft-1') - expect(again.position).toBe(1) - expect(journal.queuedMessages.list()).toHaveLength(1) - }) - - it("keeps an agent card's body as written across reopen: the queue reads nothing of its sender", async () => { - const from: AgentMessageSource = { - kind: 'agent', - senders: [ - { - party: { - address: 'structworker_1', - terminalHandle: 'structworker_1', - orcaSessionId: null - }, - name: 'Reviewer' - } - ], - orchestration: { - message: 'mail-notice', - mailbox: 'run:r1', - dispatchId: 'd1', - messages: [{ messageId: 'm1', runId: 'r1', from: 'structworker_1' }] - } - } - const first = await open() - const insert = (messageId: string, body: AgentJournalMessageItem) => - first.queuedMessages.insert({ - messageId, - body, - fingerprint: `fp-${messageId}`, - hostInstance: 'proc-1' - }) - await insert('agent-card', { ...message('You have 1 orchestration message.'), from }) - await insert('newer-kind', { - ...message('a task'), - from: { ...from, orchestration: null } - }) - await insert('malformed', message('typed')) - await first.close() - closeTestJournalHostDatabases() - const db = new Database(journalDatabasePath(root)) - // A newer build's message kind, and a value no build writes: carried as written, for clients to read. - const setFrom = db.prepare( - "UPDATE queued_messages SET body_json = json_set(body_json, '$.from', json(?)) WHERE message_id = ?" - ) - setFrom.run( - JSON.stringify({ ...from, orchestration: { message: 'task', taskId: 't1' } }), - 'newer-kind' - ) - setFrom.run(JSON.stringify('nobody'), 'malformed') - // A table from a build that also kept the sender in a column of its own. - db.exec('ALTER TABLE queued_messages ADD COLUMN source_json TEXT') - db.close() - const reopened = await open() - expect(reopened.queuedMessages.list().map((row) => [row.messageId, row.body.from])).toEqual([ - ['agent-card', from], - ['newer-kind', { ...from, orchestration: { message: 'task', taskId: 't1' } }], - ['malformed', 'nobody'] - ]) - // That older table still takes new cards. - await queueDraft(reopened, 'after') - expect(reopened.queuedMessages.list()).toHaveLength(4) - }) - - it('drafts survive epoch replacement, which deletes only journal rows', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.replaceEpochItems('handle_forked', 0, []) - expect(journal.queuedMessages.list().map((row) => row.messageId)).toEqual(['draft-1']) - }) -}) - -describe('consume', () => { - it('converts waiting → dispatched and appends the submission in one transaction', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - const row = journal.queuedMessages.get('draft-1') - expect(row?.state).toBe('dispatched') - expect(row?.consumedAs).toBe('sub-draft-1') - expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) - }) - - it('never hands a draft off under its own id: the submission names it by link, not id equality', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await expect(consumeDraft(journal, 'draft-1', { as: 'draft-1' })).rejects.toBeInstanceOf( - QueuedMessageNotConsumableError - ) - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('never records a second submission under an id it already holds, whatever state it settled in', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - const cursor = journal.cursor() - await expect( - journal.appendSubmission({ - clientMessageId: 'sub-draft-1', - payloadFingerprint: 'fp-draft-1', - body: message('queued text'), - fence: 0, - handoverRecorded: true - }) - ).rejects.toMatchObject({ code: 'journal_submission_exists' }) - // The refusal stands: re-appending would reset it to pending and hand it over again. - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - expect(journal.cursor()).toEqual(cursor) - }) - - it('a second consume of the same draft fails and appends nothing (exactly-once)', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await expect(consumeDraft(journal, 'draft-1', { as: 'second-id' })).rejects.toBeInstanceOf( - QueuedMessageNotConsumableError - ) - expect(journal.submissions().map((entry) => entry.clientMessageId)).toEqual(['sub-draft-1']) - }) - - it('a consume racing a withdraw loses and appends nothing', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.withdraw({ - messageIds: ['draft-1'], - settledByOp: 'caller\u0000op-1' - }) - await expect(consumeDraft(journal, 'draft-1')).rejects.toBeInstanceOf( - QueuedMessageNotConsumableError - ) - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - }) - - it('a consume carrying text the card no longer holds loses: an edit since the read never sends the old text', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1', 'old') - const read = journal.queuedMessages.get('draft-1')! - await journal.queuedMessages.update({ - messageId: 'draft-1', - expectedBodyFingerprint: read.fingerprint, - text: 'new' - }) - await expect( - journal.appendSubmission( - { - clientMessageId: 'sub-draft-1', - payloadFingerprint: read.fingerprint, - body: read.body, - fence: 0, - handoverRecorded: true - }, - { messageId: 'draft-1', expect: 'waiting', settledByOp: null } - ) - ).rejects.toBeInstanceOf(QueuedMessageNotConsumableError) - expect(journal.submissions()).toHaveLength(0) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) - - it('a failed submission insert rolls the draft transition back', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - const cursor = journal.cursor() - // Occupy the next sequence directly so the append's INSERT violates the - // primary key inside the transaction, after the draft was transitioned. - const db = new Database(journalDatabasePath(root)) - db.prepare( - 'INSERT INTO journal_rows (session_id, epoch, seq, ts, row_json) VALUES (?, ?, ?, ?, ?)' - ).run(IDENTITY.sessionId, cursor.epoch, cursor.sequence + 1, tick(), '{}') - db.close() - await expect(consumeDraft(journal, 'draft-1')).rejects.toThrow() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('waiting') - }) -}) - -describe('returned transition (D1/N4)', () => { - it('a non-withdrawn rejection returns the consumed draft with its reason', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('Claude refused this payload'), - rejection: PROVIDER_REFUSAL, - fence: 0 - }) - const row = journal.queuedMessages.get('draft-1') - expect(row?.state).toBe('returned') - expect(row?.returnedReason).toBe(refusal('Claude refused this payload').reason) - expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) - }) - - it('a late rejection row after acceptance settles nothing and returns no card', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'accepted', - providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, - fence: 0 - }) - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('late duplicate'), - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - }) - - it("a Stop's withdrawal before the agent received it sends the draft back to waiting, held like the rest, and it survives a restart", async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - // The Stop's own withdrawal path: the queued (not handed over) submission. - expect(await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL)).toEqual(['sub-draft-1']) - // Nothing failed: no refusal to show, its position kept, its spent id recorded. - const requeued = { - state: 'waiting', - position: 1, - holdReason: null, - consumedAs: null, - returnedReason: null, - returnedRejection: null - } - expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) - // Atomic with the rejection row: a crash before the Stop answered keeps the text. - await journal.close() - clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 - journal = await open() - expect(journal.queuedMessages.get('draft-1')).toMatchObject(requeued) - }) - - it('a draft sent back to waiting hands off again under a fresh id, never a spent one', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - // The spent id already names a rejected submission: one id, one delivery. - await expect(consumeDraft(journal, 'draft-1')).rejects.toMatchObject({ - code: 'journal_submission_exists' - }) - expect(journal.submission('sub-draft-1')?.dispatchState).toBe('rejected') - await consumeDraft(journal, 'draft-1', { as: 'fresh-1' }) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'dispatched', - consumedAs: 'fresh-1' - }) - // Both hand-offs name the draft. - expect(journal.submission('fresh-1')).toMatchObject({ - dispatchState: 'pending', - queuedMessageId: 'draft-1' - }) - expect(journal.submission('sub-draft-1')?.queuedMessageId).toBe('draft-1') - }) - - it.each([ - { by: 'the queue', origin: 'host' as const }, - { by: 'the person', origin: 'client' as const } - ])( - 'a restart between $by’s consume and handover sends the draft back to waiting', - async ({ origin }) => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1', { origin }) - await journal.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - await journal.rejectQueuedSubmissions(0, HOST_RESTARTED, (submission) => - journal.wroteBeforeOpen(submission.acceptedSequence) - ) - // Whoever sent it, it waits with no hold of its own, under the reopen's pause. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: null, - hostInstance: 'proc-1', - consumedAs: null, - returnedReason: null - }) - } - ) - - it('refuse → Send under a fresh id → refuse again returns the card again; a late duplicate of the first refusal never touches the re-send (N4)', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('first refusal'), - rejection: PROVIDER_REFUSAL, - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('returned') - // Send on the returned card re-consumes under a fresh submission id. - await consumeDraft(journal, 'draft-1', { as: 'resend-1', expect: 'returned' }) - // The earlier refusal retires with the card: the row now describes the re-send. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'dispatched', - consumedAs: 'resend-1', - returnedReason: null, - returnedRejection: null - }) - // A duplicate resolution of the FIRST submission is ignored by the journal - // and must not alter the draft's current relation. - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('duplicate of first refusal'), - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - // The re-send's own refusal returns the card, matched via consumed_as. - await journal.resolveDispatch({ - clientMessageId: 'resend-1', - state: 'rejected', - ...refusal('second refusal'), - fence: 0 - }) - const returned = journal.queuedMessages.get('draft-1') - expect(returned?.state).toBe('returned') - expect(returned?.returnedReason).toBe(refusal('second refusal').reason) - // The first refusal's fact does not outlive it: the pair is the second submission's. - expect(returned?.returnedRejection).toEqual(refusal('second refusal').rejection) - expect(returned?.returnedRejection).not.toEqual(PROVIDER_REFUSAL) - }) - - it('a rejection never revives a withdrawn draft', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('again'), - fence: 0 - }) - expect(journal.queuedMessages.get('draft-1')?.state).toBe('withdrawn') - }) - - it('the returned row and its stored reason survive epoch replacement and reopen (B1)', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('stored refusal'), - fence: 0 - }) - await journal.replaceEpochItems('handle_forked', 0, []) - await journal.close() - journal = await open() - const row = journal.queuedMessages.get('draft-1') - expect(row?.state).toBe('returned') - expect(row?.returnedReason).toBe(refusal('stored refusal').reason) - }) -}) - -describe('withdraw', () => { - it('withdraws waiting and returned rows together into op-stamped receipts', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1', 'first text') - await queueDraft(journal, 'draft-2', 'second text') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - const withdrawn = await journal.queuedMessages.withdraw({ - messageIds: ['draft-1', 'draft-2'], - settledByOp: 'caller\u0000stop-1' - }) - expect(withdrawn.map((row) => [row.messageId, row.body.blocks])).toEqual([ - ['draft-1', [{ type: 'text', text: 'first text' }]], - ['draft-2', [{ type: 'text', text: 'second text' }]] - ]) - // A lost acknowledgement replays from the tombstones, keyed by the - // caller-scoped operation key — never from the ledger. - const receipts = journal.queuedMessages.receipts('caller\u0000stop-1') - expect(receipts.map((row) => row.messageId)).toEqual(['draft-1', 'draft-2']) - // Pending or dispatched rows stay outside the withdrawable set. - const second = await journal.queuedMessages.withdraw({ - messageIds: ['draft-1'], - settledByOp: 'caller\u0000stop-2' - }) - expect(second).toHaveLength(0) - }) - - it('two callers reusing one operation id read only their own receipts', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.withdraw({ - messageIds: ['draft-1'], - settledByOp: 'caller-a\u0000op-1' - }) - expect(journal.queuedMessages.receipts('caller-b\u0000op-1')).toHaveLength(0) - expect(journal.queuedMessages.receipts('caller-a\u0000op-1')).toHaveLength(1) - }) -}) - -describe('open-time repair and retention', () => { - it('a failed repair is reported and skipped, never failing the open', async () => { - const repair = vi - .spyOn(JournalQueuedMessages.prototype, 'repairAndPrune') - .mockRejectedValueOnce(new Error('SQLITE_FULL')) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - try { - const journal = await open() - expect(warn).toHaveBeenCalledWith( - '[journal-open] queued-message repair skipped:', - expect.objectContaining({ error: 'SQLITE_FULL' }) - ) - await queueDraft(journal, 'draft-1') - expect(journal.queuedMessages.list()).toHaveLength(1) - } finally { - repair.mockRestore() - warn.mockRestore() - } - }) - - // The repair reaches the live hook's answer from the stored rejection. - it.each([ - { origin: 'client' as const, cause: 'hostRestarted' as const }, - { origin: 'host' as const, cause: 'hostRestarted' as const }, - { origin: 'client' as const, cause: 'chatClosed' as const }, - { origin: 'host' as const, cause: 'chatClosed' as const } - ])( - 'a skipped hook for a $origin hand-off cut short ($cause) is repaired at open, back to waiting', - async ({ origin, cause }) => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1', { origin }) - await journal.rejectQueuedSubmissions( - 0, - agentSessionFailureWords(agentSessionFailureFact(cause), { surface: 'rejection' }) - ) - await journal.close() - // The hook "was skipped": the draft is back to dispatched behind the stored rejection. - const db = new Database(journalDatabasePath(root)) - db.prepare( - "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" - ).run('draft-1') - db.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: null, - consumedAs: null - }) - } - ) - - it('returns a dispatched row whose loaded submission is effectively rejected (downgrade wrote no hook)', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused while downgraded'), - rejection: PROVIDER_REFUSAL, - fence: 0 - }) - await journal.close() - // Simulate the old build having written the rejection with no hook: put the - // draft back to dispatched behind the stored fact. - const db = new Database(journalDatabasePath(root)) - db.prepare( - "UPDATE queued_messages SET state = 'dispatched', returned_reason = NULL, returned_rejection = NULL WHERE message_id = ?" - ).run('draft-1') - db.close() - journal = await open() - const row = journal.queuedMessages.get('draft-1') - expect(row?.state).toBe('returned') - expect(row?.returnedReason).toBe(refusal('refused while downgraded').reason) - expect(row?.returnedRejection).toEqual(PROVIDER_REFUSAL) - }) - - it('sends back to waiting a dispatched row whose submission a Stop withdrew with no hook, never leaving it dispatched', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.rejectQueuedSubmissions(0, STOP_WITHDRAWAL) - await journal.close() - const db = new Database(journalDatabasePath(root)) - db.prepare( - "UPDATE queued_messages SET state = 'dispatched', hold_reason = NULL, consumed_as = 'sub-draft-1' WHERE message_id = ?" - ).run('draft-1') - db.close() - clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 - journal = await open() - // The same settlement the live hook applies. - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - holdReason: null, - consumedAs: null, - returnedReason: null - }) - }) - - it('keeps a dispatched row while its submission is still pending, even past the window, so a late rejection still settles it (N5)', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.close() - // Reopen "25 hours" later: the submission is still queued/pending. - clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 60 * 60 * 1000 - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.state).toBe('dispatched') - // The delivery loop's leftover rejection now sends it back to waiting. - await journal.rejectQueuedSubmissions(0, HOST_RESTARTED) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'waiting', - consumedAs: null - }) - }) - - it('prunes accepted and withdrawn rows once the replay window passes, and never waiting or returned rows', async () => { - let journal = await open() - await queueDraft(journal, 'accepted-1') - await consumeDraft(journal, 'accepted-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-accepted-1', - state: 'accepted', - providerIdentity: { provider: 'claude', sessionId: 'native-1', uuid: 'echo-1' }, - fence: 0 - }) - await queueDraft(journal, 'withdrawn-1') - await journal.queuedMessages.withdraw({ - messageIds: ['withdrawn-1'], - settledByOp: 'c\u0000op-w' - }) - await queueDraft(journal, 'waiting-1') - await queueDraft(journal, 'returned-1') - await consumeDraft(journal, 'returned-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-returned-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - await journal.close() - clock += QUEUED_MESSAGE_REPLAY_WINDOW_MS + 1_000 - journal = await open() - expect(journal.queuedMessages.list().map((row) => [row.messageId, row.state])).toEqual([ - ['waiting-1', 'waiting'], - ['returned-1', 'returned'] - ]) - }) - - it('keeps fresh tombstones inside the replay window', async () => { - let journal = await open() - await queueDraft(journal, 'withdrawn-1') - await journal.queuedMessages.withdraw({ - messageIds: ['withdrawn-1'], - settledByOp: 'c\u0000op-w' - }) - await journal.close() - clock += 1_000 - journal = await open() - expect(journal.queuedMessages.get('withdrawn-1')?.state).toBe('withdrawn') - }) -}) - -describe('holds', () => { - it('a hold is stored on the row, survives reopen, and withdraw clears it', async () => { - let journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') - await journal.close() - journal = await open() - expect(journal.queuedMessages.get('draft-1')?.holdReason).toBe('send_failed') - await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'withdrawn', - holdReason: null - }) - }) - - it('consume clears the hold in the same transaction (Send-now overrides it)', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - await consumeDraft(journal, 'draft-1') - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'dispatched', - holdReason: null - }) - }) - - it('a withdraw naming no drafts touches nothing — a Delete race with no rows costs no write', async () => { - const journal = await open() - await journal.close() - await expect( - journal.queuedMessages.withdraw({ messageIds: [], settledByOp: 'c\u0000op' }) - ).resolves.toEqual([]) - }) - - it('holds reach only waiting rows', async () => { - const journal = await open() - await queueDraft(journal, 'draft-1') - await consumeDraft(journal, 'draft-1') - await journal.resolveDispatch({ - clientMessageId: 'sub-draft-1', - state: 'rejected', - ...refusal('refused'), - fence: 0 - }) - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - expect(journal.queuedMessages.get('draft-1')).toMatchObject({ - state: 'returned', - holdReason: null - }) - }) -}) - -describe('the commit listener', () => { - it('draft-table writes fire it exactly when rows changed, so no caller publishes by hand', async () => { - const journal = await open() - let commits = 0 - journal.observeCommits(() => { - commits += 1 - }) - await queueDraft(journal, 'draft-1') - expect(commits).toBe(1) - // An idempotent replay changes nothing and stays silent. - await queueDraft(journal, 'draft-1') - expect(commits).toBe(1) - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - expect(commits).toBe(2) - await journal.queuedMessages.hold({ messageIds: ['draft-1'], reason: 'send_failed' }) - expect(commits).toBe(2) - await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op' }) - expect(commits).toBe(3) - await journal.queuedMessages.withdraw({ messageIds: ['draft-1'], settledByOp: 'c\u0000op-2' }) - expect(commits).toBe(3) - }) -}) diff --git a/src/main/native-chat/agent-session-journal/queued-message-text-update.spec.ts b/src/main/native-chat/agent-session-journal/queued-message-text-update.spec.ts new file mode 100644 index 00000000000..4b760d94cc2 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/queued-message-text-update.spec.ts @@ -0,0 +1,183 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import Database from '../../sqlite/sync-database' +import { ensureQueuedMessagesTable } from './queued-message-schema' +import { getQueuedMessage, insertQueuedMessage } from './queued-message-table' +import { updateQueuedMessageText } from './queued-message-text-update' +import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' +import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' +import { MAX_PROMPT_BYTES } from '../../../shared/rpc-contract/structured-agent-session-params' + +export function registerQueuedMessageTextUpdateTests(): void { + let db: Database.Database + const sessionId = 'folder-session' + const initial: AgentJournalMessageItem = { + kind: 'message', + role: 'user', + blocks: [{ type: 'text', text: 'base' }] + } + beforeEach(() => { + db = new Database(':memory:') + ensureQueuedMessagesTable(db) + }) + afterEach(() => db.close()) + function insert(body = initial, messageId = 'card') { + return insertQueuedMessage(db, { + sessionId, + messageId, + body, + fingerprint: agentSessionSendBodyFingerprint(sessionId, body), + hostInstance: 'remote-host', + queuedAt: { epoch: 'epoch', sequence: 4 }, + now: 1, + holdReason: 'send_failed' + }) + } + function update(text: string, fingerprint = agentSessionSendBodyFingerprint(sessionId, initial)) { + return updateQueuedMessageText(db, sessionId, { + messageId: 'card', + expectedBodyFingerprint: fingerprint, + text + }) + } + + describe('atomic queued text update', () => { + it('preserves identity, cursor, order, sender, kind, role, send mode and attachment order', () => { + const body: AgentJournalMessageItem = { + ...initial, + sentAs: 'goal', + from: { kind: 'agent', senders: [], orchestration: null }, + blocks: [ + { type: 'image-ref', path: 'before.png' }, + { type: 'text', text: 'one' }, + { type: 'image-ref', url: 'https://example.com/image.png' }, + { type: 'text', text: 'two' } + ] + } + const before = insert(body) + expect(update('edited', before.fingerprint).status).toBe('updated') + const after = getQueuedMessage(db, sessionId, 'card')! + expect(after).toEqual({ + ...before, + body: { + ...body, + blocks: [body.blocks[0]!, { type: 'text', text: 'edited' }, body.blocks[2]!] + }, + fingerprint: after.fingerprint + }) + expect(after.fingerprint).toBe(agentSessionSendBodyFingerprint(sessionId, after.body)) + }) + it('saving the same text keeps several text blocks as they are', () => { + const body: AgentJournalMessageItem = { + ...initial, + blocks: [ + { type: 'text', text: 'one' }, + { type: 'text', text: 'two' } + ] + } + const before = insert(body) + expect(update('one\ntwo', before.fingerprint).status).toBe('unchanged') + expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before) + }) + it('retains returned-card refusal and failure hold rather than requeueing', () => { + insert() + db.prepare( + "UPDATE queued_messages SET state = 'returned', returned_reason = 'refused', consumed_as = 'submission' WHERE message_id = 'card'" + ).run() + const before = getQueuedMessage(db, sessionId, 'card')! + expect(update('edit').status).toBe('updated') + expect(getQueuedMessage(db, sessionId, 'card')).toMatchObject({ + state: 'returned', + returnedReason: before.returnedReason, + consumedAs: before.consumedAs, + holdReason: before.holdReason, + position: before.position + }) + }) + it('checks the resulting body before the expected base, making lost-answer retry safe', () => { + insert() + expect(update('edit').status).toBe('updated') + expect(update('edit').status).toBe('unchanged') + expect(update('other').status).toBe('changed') + }) + it('accepts semantic A/B/A and preserves the latest host-owned sender', () => { + insert() + const base = agentSessionSendBodyFingerprint(sessionId, initial) + update('B') + const second = getQueuedMessage(db, sessionId, 'card')! + update('base', second.fingerprint) + const from: NonNullable = { + kind: 'agent', + senders: [], + orchestration: null + } + const latestBody = { ...initial, from } + db.prepare("UPDATE queued_messages SET body_json = ? WHERE message_id = 'card'").run( + JSON.stringify(latestBody) + ) + expect(agentSessionSendBodyFingerprint(sessionId, latestBody)).toBe(base) + expect(update('C', base).status).toBe('updated') + expect(getQueuedMessage(db, sessionId, 'card')?.body.from).toEqual(from) + }) + it('refuses empty, oversized, command and unsupported bodies without modifying them', () => { + insert() + expect(update(' ').status).toBe('not-editable') + expect(update('é'.repeat(MAX_PROMPT_BYTES)).status).toBe('not-editable') + db.prepare('DELETE FROM queued_messages').run() + const command = insert({ ...initial, command: { name: 'compact' } }) + expect(update('edit', command.fingerprint).status).toBe('not-editable') + expect(getQueuedMessage(db, sessionId, 'card')).toEqual(command) + db.prepare('DELETE FROM queued_messages').run() + const unsupported = insert({ + ...initial, + blocks: [{ type: 'tool-result', callId: 'tool', output: 'retained' }] + }) + expect(update('edit', unsupported.fingerprint).status).toBe('not-editable') + expect(getQueuedMessage(db, sessionId, 'card')).toEqual(unsupported) + }) + it('allows empty text with a retained attachment, and inserts text before one with none', () => { + const before = insert({ + ...initial, + blocks: [ + { type: 'text', text: 'caption' }, + { type: 'image-ref', path: 'file.png' } + ] + }) + expect(update('', before.fingerprint).status).toBe('updated') + expect(getQueuedMessage(db, sessionId, 'card')?.body.blocks).toEqual([ + { type: 'text', text: '' }, + { type: 'image-ref', path: 'file.png' } + ]) + const imageOnly = insert( + { ...initial, blocks: [{ type: 'image-ref', path: 'file.png' }] }, + 'image' + ) + expect( + updateQueuedMessageText(db, sessionId, { + messageId: 'image', + expectedBodyFingerprint: imageOnly.fingerprint, + text: 'added' + }).status + ).toBe('updated') + expect(getQueuedMessage(db, sessionId, 'image')?.body.blocks).toEqual([ + { type: 'text', text: 'added' }, + { type: 'image-ref', path: 'file.png' } + ]) + }) + it('a failed transaction rolls back body and fingerprint together', () => { + const before = insert() + db.exec('BEGIN IMMEDIATE') + update('edit') + db.exec('ROLLBACK') + expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before) + }) + it('dispatch, withdrawal and a pruned tombstone all answer gone', () => { + insert() + db.prepare("UPDATE queued_messages SET state = 'dispatched' WHERE message_id = 'card'").run() + expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'dispatched' }) + db.prepare("UPDATE queued_messages SET state = 'withdrawn' WHERE message_id = 'card'").run() + expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'withdrawn' }) + db.prepare('DELETE FROM queued_messages').run() + expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'missing' }) + }) + }) +} diff --git a/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts b/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts deleted file mode 100644 index d48ce57179d..00000000000 --- a/src/main/native-chat/agent-session-journal/queued-message-text-update.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import Database from '../../sqlite/sync-database' -import { ensureQueuedMessagesTable } from './queued-message-schema' -import { getQueuedMessage, insertQueuedMessage } from './queued-message-table' -import { updateQueuedMessageText } from './queued-message-text-update' -import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' -import { agentSessionSendBodyFingerprint } from '../../../shared/structured-agent-session-send-mutation' -import { MAX_PROMPT_BYTES } from '../../../shared/rpc-contract/structured-agent-session-params' - -let db: Database.Database -const sessionId = 'folder-session' -const initial: AgentJournalMessageItem = { - kind: 'message', - role: 'user', - blocks: [{ type: 'text', text: 'base' }] -} -beforeEach(() => { - db = new Database(':memory:') - ensureQueuedMessagesTable(db) -}) -afterEach(() => db.close()) -function insert(body = initial, messageId = 'card') { - return insertQueuedMessage(db, { - sessionId, - messageId, - body, - fingerprint: agentSessionSendBodyFingerprint(sessionId, body), - hostInstance: 'remote-host', - queuedAt: { epoch: 'epoch', sequence: 4 }, - now: 1, - holdReason: 'send_failed' - }) -} -function update(text: string, fingerprint = agentSessionSendBodyFingerprint(sessionId, initial)) { - return updateQueuedMessageText(db, sessionId, { - messageId: 'card', - expectedBodyFingerprint: fingerprint, - text - }) -} - -describe('atomic queued text update', () => { - it('preserves identity, cursor, order, sender, kind, role, send mode and attachment order', () => { - const body: AgentJournalMessageItem = { - ...initial, - sentAs: 'goal', - from: { kind: 'agent', senders: [], orchestration: null }, - blocks: [ - { type: 'image-ref', path: 'before.png' }, - { type: 'text', text: 'one' }, - { type: 'image-ref', url: 'https://example.com/image.png' }, - { type: 'text', text: 'two' } - ] - } - const before = insert(body) - expect(update('edited', before.fingerprint).status).toBe('updated') - const after = getQueuedMessage(db, sessionId, 'card')! - expect(after).toEqual({ - ...before, - body: { - ...body, - blocks: [body.blocks[0]!, { type: 'text', text: 'edited' }, body.blocks[2]!] - }, - fingerprint: after.fingerprint - }) - expect(after.fingerprint).toBe(agentSessionSendBodyFingerprint(sessionId, after.body)) - }) - it('saving the same text keeps several text blocks as they are', () => { - const body: AgentJournalMessageItem = { - ...initial, - blocks: [ - { type: 'text', text: 'one' }, - { type: 'text', text: 'two' } - ] - } - const before = insert(body) - expect(update('one\ntwo', before.fingerprint).status).toBe('unchanged') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before) - }) - it('retains returned-card refusal and failure hold rather than requeueing', () => { - insert() - db.prepare( - "UPDATE queued_messages SET state = 'returned', returned_reason = 'refused', consumed_as = 'submission' WHERE message_id = 'card'" - ).run() - const before = getQueuedMessage(db, sessionId, 'card')! - expect(update('edit').status).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'card')).toMatchObject({ - state: 'returned', - returnedReason: before.returnedReason, - consumedAs: before.consumedAs, - holdReason: before.holdReason, - position: before.position - }) - }) - it('checks the resulting body before the expected base, making lost-answer retry safe', () => { - insert() - expect(update('edit').status).toBe('updated') - expect(update('edit').status).toBe('unchanged') - expect(update('other').status).toBe('changed') - }) - it('accepts semantic A/B/A and preserves the latest host-owned sender', () => { - insert() - const base = agentSessionSendBodyFingerprint(sessionId, initial) - update('B') - const second = getQueuedMessage(db, sessionId, 'card')! - update('base', second.fingerprint) - const from: NonNullable = { - kind: 'agent', - senders: [], - orchestration: null - } - const latestBody = { ...initial, from } - db.prepare("UPDATE queued_messages SET body_json = ? WHERE message_id = 'card'").run( - JSON.stringify(latestBody) - ) - expect(agentSessionSendBodyFingerprint(sessionId, latestBody)).toBe(base) - expect(update('C', base).status).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'card')?.body.from).toEqual(from) - }) - it('refuses empty, oversized, command and unsupported bodies without modifying them', () => { - insert() - expect(update(' ').status).toBe('not-editable') - expect(update('é'.repeat(MAX_PROMPT_BYTES)).status).toBe('not-editable') - db.prepare('DELETE FROM queued_messages').run() - const command = insert({ ...initial, command: { name: 'compact' } }) - expect(update('edit', command.fingerprint).status).toBe('not-editable') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(command) - db.prepare('DELETE FROM queued_messages').run() - const unsupported = insert({ - ...initial, - blocks: [{ type: 'tool-result', callId: 'tool', output: 'retained' }] - }) - expect(update('edit', unsupported.fingerprint).status).toBe('not-editable') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(unsupported) - }) - it('allows empty text with a retained attachment, and inserts text before one with none', () => { - const before = insert({ - ...initial, - blocks: [ - { type: 'text', text: 'caption' }, - { type: 'image-ref', path: 'file.png' } - ] - }) - expect(update('', before.fingerprint).status).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'card')?.body.blocks).toEqual([ - { type: 'text', text: '' }, - { type: 'image-ref', path: 'file.png' } - ]) - const imageOnly = insert( - { ...initial, blocks: [{ type: 'image-ref', path: 'file.png' }] }, - 'image' - ) - expect( - updateQueuedMessageText(db, sessionId, { - messageId: 'image', - expectedBodyFingerprint: imageOnly.fingerprint, - text: 'added' - }).status - ).toBe('updated') - expect(getQueuedMessage(db, sessionId, 'image')?.body.blocks).toEqual([ - { type: 'text', text: 'added' }, - { type: 'image-ref', path: 'file.png' } - ]) - }) - it('a failed transaction rolls back body and fingerprint together', () => { - const before = insert() - db.exec('BEGIN IMMEDIATE') - update('edit') - db.exec('ROLLBACK') - expect(getQueuedMessage(db, sessionId, 'card')).toEqual(before) - }) - it('dispatch, withdrawal and a pruned tombstone all answer gone', () => { - insert() - db.prepare("UPDATE queued_messages SET state = 'dispatched' WHERE message_id = 'card'").run() - expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'dispatched' }) - db.prepare("UPDATE queued_messages SET state = 'withdrawn' WHERE message_id = 'card'").run() - expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'withdrawn' }) - db.prepare('DELETE FROM queued_messages').run() - expect(update('edit')).toMatchObject({ status: 'gone', disposition: 'missing' }) - }) -}) diff --git a/src/main/persistence/loading-store/durable-settings-write.spec.ts b/src/main/persistence/loading-store/durable-settings-write.spec.ts new file mode 100644 index 00000000000..ab48419cd7c --- /dev/null +++ b/src/main/persistence/loading-store/durable-settings-write.spec.ts @@ -0,0 +1,89 @@ +import { expect, it, vi } from 'vitest' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +export function registerDurableSettingsWriteTests(): void { + it('waits for the SQLite write before returning or publishing the preference', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const onChanged = vi.fn() + store.onSettingsChanged(onChanged) + let settled = false + const pending = store.updateSettingsAndFlush( + { alwaysForceDeleteWorktrees: true }, + { notifyListeners: true, originWebContentsId: 7 } + ) + void pending.then(() => { + settled = true + }) + await started.promise + expect(settled).toBe(false) + expect(onChanged).not.toHaveBeenCalled() + store.updateSettings({ theme: 'dark' }) + release.resolve() + expect((await pending).theme).toBe('dark') + expect(readState().settings.alwaysForceDeleteWorktrees).toBe(true) + expect(onChanged).toHaveBeenCalledWith( + { alwaysForceDeleteWorktrees: true }, + expect.objectContaining({ alwaysForceDeleteWorktrees: true }), + 7 + ) + }) + + it('rejects a failed disk write and rolls back without publishing the preference', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const previous = store.getSettings().alwaysForceDeleteWorktrees + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full')) + const onChanged = vi.fn() + store.onSettingsChanged(onChanged) + await expect( + store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true }, { notifyListeners: true }) + ).rejects.toThrow('Disk full') + expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous) + expect(readState().settings.alwaysForceDeleteWorktrees).toBe(previous) + expect(onChanged).not.toHaveBeenCalled() + }) + + it('rolls back the preference while retaining unrelated concurrent settings edits', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + const previous = store.getSettings().alwaysForceDeleteWorktrees + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw new Error('Disk full') + }) + const pending = store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true }) + const rejected = expect(pending).rejects.toThrow('Disk full') + await started.promise + store.updateSettings({ theme: 'dark' }) + release.resolve() + await rejected + expect(store.getSettings()).toMatchObject({ + alwaysForceDeleteWorktrees: previous, + theme: 'dark' + }) + }) + + it('removes previously absent optional keys after a failed preference payload write', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + delete store.getSettings().editorWordWrap + const previous = store.getSettings().alwaysForceDeleteWorktrees + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full')) + await expect( + store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true, editorWordWrap: false }) + ).rejects.toThrow('Disk full') + expect(Object.hasOwn(store.getSettings(), 'editorWordWrap')).toBe(false) + expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous) + }) +} diff --git a/src/main/persistence/loading-store/durable-settings-write.test.ts b/src/main/persistence/loading-store/durable-settings-write.test.ts deleted file mode 100644 index 3a415c8bd66..00000000000 --- a/src/main/persistence/loading-store/durable-settings-write.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { - createWorkerMaintenanceFixture, - maintenanceBarrier -} from './profile-state-maintenance-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -it('waits for the SQLite write before returning or publishing the preference', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeSerializedDomains.bind(authority) - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { - started.resolve() - await release.promise - await write(domains) - }) - const onChanged = vi.fn() - store.onSettingsChanged(onChanged) - let settled = false - const pending = store.updateSettingsAndFlush( - { alwaysForceDeleteWorktrees: true }, - { notifyListeners: true, originWebContentsId: 7 } - ) - void pending.then(() => { - settled = true - }) - await started.promise - expect(settled).toBe(false) - expect(onChanged).not.toHaveBeenCalled() - store.updateSettings({ theme: 'dark' }) - release.resolve() - expect((await pending).theme).toBe('dark') - expect(readState().settings.alwaysForceDeleteWorktrees).toBe(true) - expect(onChanged).toHaveBeenCalledWith( - { alwaysForceDeleteWorktrees: true }, - expect.objectContaining({ alwaysForceDeleteWorktrees: true }), - 7 - ) -}) - -it('rejects a failed disk write and rolls back without publishing the preference', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - const previous = store.getSettings().alwaysForceDeleteWorktrees - vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full')) - const onChanged = vi.fn() - store.onSettingsChanged(onChanged) - await expect( - store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true }, { notifyListeners: true }) - ).rejects.toThrow('Disk full') - expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous) - expect(readState().settings.alwaysForceDeleteWorktrees).toBe(previous) - expect(onChanged).not.toHaveBeenCalled() -}) - -it('rolls back the preference while retaining unrelated concurrent settings edits', async () => { - const { store, authority } = await createWorkerMaintenanceFixture() - const previous = store.getSettings().alwaysForceDeleteWorktrees - const started = maintenanceBarrier() - const release = maintenanceBarrier() - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { - started.resolve() - await release.promise - throw new Error('Disk full') - }) - const pending = store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true }) - const rejected = expect(pending).rejects.toThrow('Disk full') - await started.promise - store.updateSettings({ theme: 'dark' }) - release.resolve() - await rejected - expect(store.getSettings()).toMatchObject({ - alwaysForceDeleteWorktrees: previous, - theme: 'dark' - }) -}) - -it('removes previously absent optional keys after a failed preference payload write', async () => { - const { store, authority } = await createWorkerMaintenanceFixture() - delete store.getSettings().editorWordWrap - const previous = store.getSettings().alwaysForceDeleteWorktrees - vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('Disk full')) - await expect( - store.updateSettingsAndFlush({ alwaysForceDeleteWorktrees: true, editorWordWrap: false }) - ).rejects.toThrow('Disk full') - expect(Object.hasOwn(store.getSettings(), 'editorWordWrap')).toBe(false) - expect(store.getSettings().alwaysForceDeleteWorktrees).toBe(previous) -}) diff --git a/src/main/persistence/loading-store/profile-state-caller-cancellation.spec.ts b/src/main/persistence/loading-store/profile-state-caller-cancellation.spec.ts new file mode 100644 index 00000000000..3da06bc97fd --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-caller-cancellation.spec.ts @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { agentHookServer } from '../../agent-hooks/server' +import { + clearMigrationUnsupportedPty, + setMigrationUnsupportedPty +} from '../../agent-hooks/migration-unsupported-pty-state' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { makePaneKey } from '../../../shared/stable-pane-id' +import { openProfileStateDatabase } from '../profile-state/profile-state-database' +import { readProfileStateRevision } from '../profile-state/profile-state-revision' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +export function registerProfileStateCallerCancellationTests(): void { + const SOURCE_A = makePaneKey('tab-a', '11111111-1111-4111-8111-111111111111') + const TARGET_A = makePaneKey('tab-a2', '22222222-2222-4222-8222-222222222222') + const TARGET_A_2 = makePaneKey('tab-a3', '55555555-5555-4555-8555-555555555555') + const TARGET_A_3 = makePaneKey('tab-a4', '66666666-6666-4666-8666-666666666666') + const SOURCE_B = makePaneKey('tab-b', '33333333-3333-4333-8333-333333333333') + const TARGET_B = makePaneKey('tab-b2', '44444444-4444-4444-8444-444444444444') + const MIGRATION_ENTRY = { + ptyId: 'pty-legacy', + reason: 'legacy-numeric-pane-key' as const, + source: 'local' as const, + updatedAt: 5 + } + + afterEach(() => { + agentHookServer.clearPaneKeyAliasesForPty('pty-a') + agentHookServer.clearPaneKeyAliasesForPty('pty-b') + clearMigrationUnsupportedPty(MIGRATION_ENTRY.ptyId) + }) + + /** Seed two durable aliases, then hold the next alias commit at the given point. */ + async function holdAliasCommit(point: 'after-commit' | 'before-failure') { + const fixture = await createWorkerMaintenanceFixture() + const { store, authority } = fixture + agentHookServer.transferPaneAuthority(SOURCE_A, TARGET_A, 'pty-a', 1) + agentHookServer.transferPaneAuthority(SOURCE_B, TARGET_B, 'pty-b', 1) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + const writes = vi.spyOn(authority, 'writeSerializedDomains') + writes.mockImplementationOnce(async (domains) => { + if (point === 'after-commit') { + await write(domains) + } + started.resolve() + await release.promise + if (point === 'before-failure') { + throw new ProfileStateWriterError('SQLITE_BUSY', 'busy', 'known-failure') + } + }) + // Commit A also carries a section no later edit touches, so only restored intent can save it. + agentHookServer.transferPaneAuthority(TARGET_A, TARGET_A_2, 'pty-a', 2) + setMigrationUnsupportedPty(MIGRATION_ENTRY) + const controller = new AbortController() + const pending = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const settled = pending.catch((error: unknown) => error) + await started.promise + // Newer edits arrive while commit A is unacknowledged. + agentHookServer.transferPaneAuthority(TARGET_A_2, TARGET_A_3, 'pty-a', 3) + agentHookServer.clearPaneKeyAliasesForPty('pty-b') + store.setWorkspaceSession( + { ...getDefaultWorkspaceSession(), activeRepoId: 'remote-repo' }, + 'ssh:ssh-1' + ) + return { ...fixture, writes, controller, release, settled } + } + + function expectLatestOwedState(state: { + legacyPaneKeyAliasEntries?: unknown + migrationUnsupportedPtyEntries?: unknown + workspaceSessionsByHostId?: Record + }): void { + expect(state.migrationUnsupportedPtyEntries).toEqual([MIGRATION_ENTRY]) + expect(state.legacyPaneKeyAliasEntries).toEqual([ + { ptyId: 'pty-a', legacyPaneKey: SOURCE_A, stablePaneKey: TARGET_A_3, updatedAt: 3 } + ]) + expect(state.workspaceSessionsByHostId?.['ssh:ssh-1']).toMatchObject({ + activeRepoId: 'remote-repo' + }) + } + + describe('profile flush caller cancellation', () => { + it('keeps newer alias, deletion, and remote edits owed after a delayed alias commit', async () => { + const { store, readState, writes, controller, release, settled, databaseFile, profileId } = + await holdAliasCommit('after-commit') + controller.abort() + expect(await settled).toMatchObject({ message: expect.stringContaining('aborted') }) + // Commit A is durable, but nothing newer is reported or persisted early. + expect(readState().legacyPaneKeyAliasEntries).toEqual( + expect.arrayContaining([expect.objectContaining({ ptyId: 'pty-a', updatedAt: 2 })]) + ) + release.resolve() + await store.flushPendingOrThrowAsync() + expectLatestOwedState(readState()) + // A's replacement is not replayed: the drain writes only the newer values. + expect(writes).toHaveBeenCalledTimes(2) + const readRevision = () => { + const opened = openProfileStateDatabase(databaseFile, profileId) + try { + return readProfileStateRevision(opened.db) + } finally { + opened.db.close() + } + } + const revision = readRevision() + await store.flushPendingOrThrowAsync() + expect(readRevision()).toBe(revision) + }) + + it('restores dirty intent after a confirmed failure that held newer edits', async () => { + const { store, readState, release, settled } = await holdAliasCommit('before-failure') + release.resolve() + expect(await settled).toMatchObject({ code: 'SQLITE_BUSY', outcome: 'known-failure' }) + await store.flushPendingOrThrowAsync() + expectLatestOwedState(readState()) + }) + + it('releases a canceled waiter while its admitted write completes and later saving continues', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + await write(domains) + started.resolve() + await release.promise + }) + const abort = vi.spyOn(authority, 'abort') + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const pending = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const rejected = expect(pending).rejects.toThrow('aborted') + let settled = false + void pending.catch(() => { + settled = true + }) + await started.promise + controller.abort() + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(abort).not.toHaveBeenCalled() + expect(settled).toBe(true) + expect(() => authority.assertWritable()).not.toThrow() + } finally { + release.resolve() + await rejected + } + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState().settings.theme).toBe('light') + }) + + it('keeps an abandoned write ordered before the final checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const abandoned = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const rejected = expect(abandoned).rejects.toThrow('aborted') + await started.promise + controller.abort() + store.getWorkspaceSession().activeTabId = 'shutdown-edit' + const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const final = store.flushFinalOrThrowAsync() + const result = final.catch((error: unknown) => error) + try { + await new Promise((resolve) => setImmediate(resolve)) + expect(capture).not.toHaveBeenCalled() + } finally { + release.resolve() + await rejected + } + await expect(result).resolves.toBeUndefined() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'shutdown-edit' } + }) + }) + }) +} diff --git a/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts b/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts deleted file mode 100644 index e42b6ec6425..00000000000 --- a/src/main/persistence/loading-store/profile-state-caller-cancellation.test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { agentHookServer } from '../../agent-hooks/server' -import { - clearMigrationUnsupportedPty, - setMigrationUnsupportedPty -} from '../../agent-hooks/migration-unsupported-pty-state' -import { getDefaultWorkspaceSession } from '../../../shared/constants' -import { makePaneKey } from '../../../shared/stable-pane-id' -import { openProfileStateDatabase } from '../profile-state/profile-state-database' -import { readProfileStateRevision } from '../profile-state/profile-state-revision' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' -import { - createWorkerMaintenanceFixture, - maintenanceBarrier -} from './profile-state-maintenance-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -const SOURCE_A = makePaneKey('tab-a', '11111111-1111-4111-8111-111111111111') -const TARGET_A = makePaneKey('tab-a2', '22222222-2222-4222-8222-222222222222') -const TARGET_A_2 = makePaneKey('tab-a3', '55555555-5555-4555-8555-555555555555') -const TARGET_A_3 = makePaneKey('tab-a4', '66666666-6666-4666-8666-666666666666') -const SOURCE_B = makePaneKey('tab-b', '33333333-3333-4333-8333-333333333333') -const TARGET_B = makePaneKey('tab-b2', '44444444-4444-4444-8444-444444444444') -const MIGRATION_ENTRY = { - ptyId: 'pty-legacy', - reason: 'legacy-numeric-pane-key' as const, - source: 'local' as const, - updatedAt: 5 -} - -afterEach(() => { - agentHookServer.clearPaneKeyAliasesForPty('pty-a') - agentHookServer.clearPaneKeyAliasesForPty('pty-b') - clearMigrationUnsupportedPty(MIGRATION_ENTRY.ptyId) -}) - -/** Seed two durable aliases, then hold the next alias commit at the given point. */ -async function holdAliasCommit(point: 'after-commit' | 'before-failure') { - const fixture = await createWorkerMaintenanceFixture() - const { store, authority } = fixture - agentHookServer.transferPaneAuthority(SOURCE_A, TARGET_A, 'pty-a', 1) - agentHookServer.transferPaneAuthority(SOURCE_B, TARGET_B, 'pty-b', 1) - await store.flushPendingOrThrowAsync() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeSerializedDomains.bind(authority) - const writes = vi.spyOn(authority, 'writeSerializedDomains') - writes.mockImplementationOnce(async (domains) => { - if (point === 'after-commit') { - await write(domains) - } - started.resolve() - await release.promise - if (point === 'before-failure') { - throw new ProfileStateWriterError('SQLITE_BUSY', 'busy', 'known-failure') - } - }) - // Commit A also carries a section no later edit touches, so only restored intent can save it. - agentHookServer.transferPaneAuthority(TARGET_A, TARGET_A_2, 'pty-a', 2) - setMigrationUnsupportedPty(MIGRATION_ENTRY) - const controller = new AbortController() - const pending = store.flushPendingOrThrowAsync({ signal: controller.signal }) - const settled = pending.catch((error: unknown) => error) - await started.promise - // Newer edits arrive while commit A is unacknowledged. - agentHookServer.transferPaneAuthority(TARGET_A_2, TARGET_A_3, 'pty-a', 3) - agentHookServer.clearPaneKeyAliasesForPty('pty-b') - store.setWorkspaceSession( - { ...getDefaultWorkspaceSession(), activeRepoId: 'remote-repo' }, - 'ssh:ssh-1' - ) - return { ...fixture, writes, controller, release, settled } -} - -function expectLatestOwedState(state: { - legacyPaneKeyAliasEntries?: unknown - migrationUnsupportedPtyEntries?: unknown - workspaceSessionsByHostId?: Record -}): void { - expect(state.migrationUnsupportedPtyEntries).toEqual([MIGRATION_ENTRY]) - expect(state.legacyPaneKeyAliasEntries).toEqual([ - { ptyId: 'pty-a', legacyPaneKey: SOURCE_A, stablePaneKey: TARGET_A_3, updatedAt: 3 } - ]) - expect(state.workspaceSessionsByHostId?.['ssh:ssh-1']).toMatchObject({ - activeRepoId: 'remote-repo' - }) -} - -describe('profile flush caller cancellation', () => { - it('keeps newer alias, deletion, and remote edits owed after a delayed alias commit', async () => { - const { store, readState, writes, controller, release, settled, databaseFile, profileId } = - await holdAliasCommit('after-commit') - controller.abort() - expect(await settled).toMatchObject({ message: expect.stringContaining('aborted') }) - // Commit A is durable, but nothing newer is reported or persisted early. - expect(readState().legacyPaneKeyAliasEntries).toEqual( - expect.arrayContaining([expect.objectContaining({ ptyId: 'pty-a', updatedAt: 2 })]) - ) - release.resolve() - await store.flushPendingOrThrowAsync() - expectLatestOwedState(readState()) - // A's replacement is not replayed: the drain writes only the newer values. - expect(writes).toHaveBeenCalledTimes(2) - const readRevision = () => { - const opened = openProfileStateDatabase(databaseFile, profileId) - try { - return readProfileStateRevision(opened.db) - } finally { - opened.db.close() - } - } - const revision = readRevision() - await store.flushPendingOrThrowAsync() - expect(readRevision()).toBe(revision) - }) - - it('restores dirty intent after a confirmed failure that held newer edits', async () => { - const { store, readState, release, settled } = await holdAliasCommit('before-failure') - release.resolve() - expect(await settled).toMatchObject({ code: 'SQLITE_BUSY', outcome: 'known-failure' }) - await store.flushPendingOrThrowAsync() - expectLatestOwedState(readState()) - }) - - it('releases a canceled waiter while its admitted write completes and later saving continues', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeSerializedDomains.bind(authority) - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { - await write(domains) - started.resolve() - await release.promise - }) - const abort = vi.spyOn(authority, 'abort') - const controller = new AbortController() - store.updateSettings({ theme: 'dark' }) - const pending = store.flushPendingOrThrowAsync({ signal: controller.signal }) - const rejected = expect(pending).rejects.toThrow('aborted') - let settled = false - void pending.catch(() => { - settled = true - }) - await started.promise - controller.abort() - try { - await new Promise((resolve) => setImmediate(resolve)) - expect(abort).not.toHaveBeenCalled() - expect(settled).toBe(true) - expect(() => authority.assertWritable()).not.toThrow() - } finally { - release.resolve() - await rejected - } - await store.runDurableMutation(() => { - store.updateSettings({ theme: 'light' }) - return { value: undefined } - }) - expect(readState().settings.theme).toBe('light') - }) - - it('keeps an abandoned write ordered before the final checkpoint', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeSerializedDomains.bind(authority) - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { - started.resolve() - await release.promise - await write(domains) - }) - const controller = new AbortController() - store.updateSettings({ theme: 'dark' }) - const abandoned = store.flushPendingOrThrowAsync({ signal: controller.signal }) - const rejected = expect(abandoned).rejects.toThrow('aborted') - await started.promise - controller.abort() - store.getWorkspaceSession().activeTabId = 'shutdown-edit' - const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') - const final = store.flushFinalOrThrowAsync() - const result = final.catch((error: unknown) => error) - try { - await new Promise((resolve) => setImmediate(resolve)) - expect(capture).not.toHaveBeenCalled() - } finally { - release.resolve() - await rejected - } - await expect(result).resolves.toBeUndefined() - expect(readState()).toMatchObject({ - settings: { theme: 'dark' }, - workspaceSession: { activeTabId: 'shutdown-edit' } - }) - }) -}) diff --git a/src/main/persistence/loading-store/profile-state-durable-writes.test.ts b/src/main/persistence/loading-store/profile-state-durable-writes.test.ts new file mode 100644 index 00000000000..81cc25c3352 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-durable-writes.test.ts @@ -0,0 +1,27 @@ +import { describe, vi } from 'vitest' +import { registerProfileStateWorkerCoordinationTests } from './profile-state-worker-coordination.spec' +import { registerProfileStateWorkerSecretRetentionTests } from './profile-state-worker-secret-retention.spec' +import { registerProfileStateWriteBatchingTests } from './profile-state-write-batching.spec' +import { registerPtyBindingAsyncDurabilityTests } from './pty-binding-async-durability.spec' +import { registerPtyBindingCreatedSurfaceRollbackTests } from './pty-binding-created-surface-rollback.spec' +import { registerPtyBindingRestartRetirementTests } from './pty-binding-restart-retirement.spec' +import { registerPtyRetirementAsyncDurabilityTests } from './pty-retirement-async-durability.spec' +import { registerWorkspaceSessionWriteListenersTests } from './workspace-session-write-listeners.spec' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('profile-state-worker-coordination', registerProfileStateWorkerCoordinationTests) +describe('profile-state-worker-secret-retention', registerProfileStateWorkerSecretRetentionTests) +describe('profile-state-write-batching', registerProfileStateWriteBatchingTests) +describe('pty-binding-async-durability', registerPtyBindingAsyncDurabilityTests) +describe('pty-binding-created-surface-rollback', registerPtyBindingCreatedSurfaceRollbackTests) +describe('pty-binding-restart-retirement', registerPtyBindingRestartRetirementTests) +describe('pty-retirement-async-durability', registerPtyRetirementAsyncDurabilityTests) +describe('workspace-session-write-listeners', registerWorkspaceSessionWriteListenersTests) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-contracts.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-contracts.test.ts new file mode 100644 index 00000000000..d3b4ad1b58a --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-contracts.test.ts @@ -0,0 +1,29 @@ +import { describe, vi } from 'vitest' +import { registerDurableSettingsWriteTests } from './durable-settings-write.spec' +import { registerProfileStateCallerCancellationTests } from './profile-state-caller-cancellation.spec' +import { registerProfileStateMaintenanceFinalFailureTests } from './profile-state-maintenance-final-failure.spec' +import { registerProfileStateMaintenanceRecoveryTests } from './profile-state-maintenance-recovery.spec' +import { registerProfileStateMaintenanceTests } from './profile-state-maintenance.spec' +import { registerProfileStatePtyRetirementFinalizationTests } from './profile-state-pty-retirement-finalization.spec' + +vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../../telemetry/cohort-classifier', () => ({ + getCohortAtEmit: () => ({ nth_repo_added: 2 }) +})) +vi.mock('../../ssh/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +describe('durable-settings-write', registerDurableSettingsWriteTests) +describe('profile-state-caller-cancellation', registerProfileStateCallerCancellationTests) +describe( + 'profile-state-maintenance-final-failure', + registerProfileStateMaintenanceFinalFailureTests +) +describe('profile-state-maintenance-recovery', registerProfileStateMaintenanceRecoveryTests) +describe('profile-state-maintenance', registerProfileStateMaintenanceTests) +describe( + 'profile-state-pty-retirement-finalization', + registerProfileStatePtyRetirementFinalizationTests +) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-final-failure.spec.ts b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.spec.ts new file mode 100644 index 00000000000..140e0114721 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.spec.ts @@ -0,0 +1,81 @@ +import { existsSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +export function registerProfileStateMaintenanceFinalFailureTests(): void { + describe('quit during failed profile maintenance', () => { + it('retries a known failed checkpoint and persists shutdown edits before closing', async () => { + const { store, authority, readState, dataFile } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.flushPendingOrThrowAsync() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw new Error('SQLITE_BUSY') + }) + store.updateSettings({ theme: 'dark' }) + const failed = expect(store.beginProfileMaintenance()).rejects.toThrow('SQLITE_BUSY') + await started.promise + store.getWorkspaceSession().activeTabId = 'last-tab' + const final = store.flushFinalOrThrowAsync() + const result = final.catch((error: unknown) => error) + release.resolve() + await failed + await expect(result).resolves.toBeUndefined() + expect(checkpoint).toHaveBeenCalledTimes(2) + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'last-tab' } + }) + expect(existsSync(dataFile)).toBe(false) + }) + + it.each(['indeterminate', 'changed-source'] as const)( + 'keeps %s maintenance fenced during quit', + async (kind) => { + const { store, authority, readState, peer } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const durable = readState() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const failure = + kind === 'indeterminate' + ? new ProfileStateWriterError('test-unknown-commit', 'commit outcome unknown', kind) + : new Error('SQLITE_BUSY') + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async () => { + started.resolve() + await release.promise + if (kind === 'changed-source') { + const other = peer() + try { + other.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + } finally { + other.close() + } + } + throw failure + }) + store.updateSettings({ theme: 'dark' }) + const failed = expect(store.beginProfileMaintenance()).rejects.toBe(failure) + await started.promise + const final = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + release.resolve() + await Promise.all([failed, final]) + expect(checkpoint).toHaveBeenCalledOnce() + expect(readState()).toEqual( + kind === 'changed-source' ? { ...durable, peer: { preserved: true } } : durable + ) + } + ) + }) +} diff --git a/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts deleted file mode 100644 index 026f7f6744c..00000000000 --- a/src/main/persistence/loading-store/profile-state-maintenance-final-failure.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { existsSync } from 'node:fs' -import { describe, expect, it, vi } from 'vitest' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' -import { - createWorkerMaintenanceFixture, - maintenanceBarrier -} from './profile-state-maintenance-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -describe('quit during failed profile maintenance', () => { - it('retries a known failed checkpoint and persists shutdown edits before closing', async () => { - const { store, authority, readState, dataFile } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await store.flushPendingOrThrowAsync() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const checkpoint = vi - .spyOn(authority, 'writeCompleteSerializedDomains') - .mockImplementationOnce(async () => { - started.resolve() - await release.promise - throw new Error('SQLITE_BUSY') - }) - store.updateSettings({ theme: 'dark' }) - const failed = expect(store.beginProfileMaintenance()).rejects.toThrow('SQLITE_BUSY') - await started.promise - store.getWorkspaceSession().activeTabId = 'last-tab' - const final = store.flushFinalOrThrowAsync() - const result = final.catch((error: unknown) => error) - release.resolve() - await failed - await expect(result).resolves.toBeUndefined() - expect(checkpoint).toHaveBeenCalledTimes(2) - expect(readState()).toMatchObject({ - settings: { theme: 'dark' }, - workspaceSession: { activeTabId: 'last-tab' } - }) - expect(existsSync(dataFile)).toBe(false) - }) - - it.each(['indeterminate', 'changed-source'] as const)( - 'keeps %s maintenance fenced during quit', - async (kind) => { - const { store, authority, readState, peer } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const durable = readState() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const failure = - kind === 'indeterminate' - ? new ProfileStateWriterError('test-unknown-commit', 'commit outcome unknown', kind) - : new Error('SQLITE_BUSY') - const checkpoint = vi - .spyOn(authority, 'writeCompleteSerializedDomains') - .mockImplementationOnce(async () => { - started.resolve() - await release.promise - if (kind === 'changed-source') { - const other = peer() - try { - other.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) - } finally { - other.close() - } - } - throw failure - }) - store.updateSettings({ theme: 'dark' }) - const failed = expect(store.beginProfileMaintenance()).rejects.toBe(failure) - await started.promise - const final = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) - release.resolve() - await Promise.all([failed, final]) - expect(checkpoint).toHaveBeenCalledOnce() - expect(readState()).toEqual( - kind === 'changed-source' ? { ...durable, peer: { preserved: true } } : durable - ) - } - ) -}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance-recovery.spec.ts b/src/main/persistence/loading-store/profile-state-maintenance-recovery.spec.ts new file mode 100644 index 00000000000..be85d70f827 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance-recovery.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from 'vitest' +import * as backupWorker from '../profile-state/profile-state-backup-worker' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +export function registerProfileStateMaintenanceRecoveryTests(): void { + describe('failed maintenance recovery', () => { + it.each(['maintenance', 'final', 'freeze'] as const)( + '%s cancels an active backup after a routine flush completes', + async (kind) => { + const { store } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const canceled = maintenanceBarrier() + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (_job, options) => { + started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() + throw new Error('backup aborted') + } + ) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await started.promise + await store.flushPendingOrThrowAsync() + const stop = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : store.freezeWritesAsync() + await canceled.promise + await stop + } + ) + + it('cancels between checkpoints without aborting an acknowledged write', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce( + async (domains) => { + started.resolve() + await release.promise + await write(domains) + } + ) + const abort = vi.spyOn(authority, 'abort') + const controller = new AbortController() + store.updateSettings({ theme: 'dark' }) + const pending = store.beginProfileMaintenance({ signal: controller.signal }) + const rejected = expect(pending).rejects.toThrow('aborted') + await started.promise + controller.abort() + release.resolve() + await rejected + expect(abort).not.toHaveBeenCalled() + store.setWorkspaceSession({ + ...store.getWorkspaceSession(), + activeTabId: 'after-cancellation' + }) + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'after-cancellation' } + }) + }) + + it('restores the writer and snapshot admission after a known failed checkpoint', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + store.updateSettings({ theme: 'dark' }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') + + store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-failure' }) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'after-failure' } + }) + await (await store.beginProfileMaintenance()).resume() + }) + + it('keeps changed storage fenced when recovering a known failure', async () => { + const { store, authority, peer, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ theme: 'dark' }) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce(async () => { + const writer = peer() + writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + writer.close() + throw new Error('disk refused') + }) + await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + expect(readState().peer).toEqual({ preserved: true }) + }) + + it('does not extend the maintenance checkpoint for unadmitted saves', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeCompleteSerializedDomains.bind(authority) + const checkpoint = vi + .spyOn(authority, 'writeCompleteSerializedDomains') + .mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + const pending = store.beginProfileMaintenance() + await started.promise + for (let terminalFontSize = 12; terminalFontSize < 32; terminalFontSize++) { + store.updateSettings({ terminalFontSize }) + } + release.resolve() + const maintenance = await pending + expect(checkpoint).toHaveBeenCalledOnce() + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(readState().settings.terminalFontSize).toBe(31) + }) + }) +} diff --git a/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts b/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts deleted file mode 100644 index 84a849b3b26..00000000000 --- a/src/main/persistence/loading-store/profile-state-maintenance-recovery.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import * as backupWorker from '../profile-state/profile-state-backup-worker' -import { - createWorkerMaintenanceFixture, - maintenanceBarrier -} from './profile-state-maintenance-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -describe('failed maintenance recovery', () => { - it.each(['maintenance', 'final', 'freeze'] as const)( - '%s cancels an active backup after a routine flush completes', - async (kind) => { - const { store } = await createWorkerMaintenanceFixture() - const started = maintenanceBarrier() - const canceled = maintenanceBarrier() - vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( - async (_job, options) => { - started.resolve() - await new Promise((resolve) => - options?.signal?.addEventListener('abort', () => resolve(), { once: true }) - ) - canceled.resolve() - throw new Error('backup aborted') - } - ) - await store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await started.promise - await store.flushPendingOrThrowAsync() - const stop = - kind === 'maintenance' - ? store.beginProfileMaintenance() - : kind === 'final' - ? store.flushFinalOrThrowAsync() - : store.freezeWritesAsync() - await canceled.promise - await stop - } - ) - - it('cancels between checkpoints without aborting an acknowledged write', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeCompleteSerializedDomains.bind(authority) - vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce( - async (domains) => { - started.resolve() - await release.promise - await write(domains) - } - ) - const abort = vi.spyOn(authority, 'abort') - const controller = new AbortController() - store.updateSettings({ theme: 'dark' }) - const pending = store.beginProfileMaintenance({ signal: controller.signal }) - const rejected = expect(pending).rejects.toThrow('aborted') - await started.promise - controller.abort() - release.resolve() - await rejected - expect(abort).not.toHaveBeenCalled() - store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-cancellation' }) - await store.flushPendingOrThrowAsync() - expect(readState()).toMatchObject({ - settings: { theme: 'dark' }, - workspaceSession: { activeTabId: 'after-cancellation' } - }) - }) - - it('restores the writer and snapshot admission after a known failed checkpoint', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( - new Error('disk refused') - ) - store.updateSettings({ theme: 'dark' }) - await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') - - store.setWorkspaceSession({ ...store.getWorkspaceSession(), activeTabId: 'after-failure' }) - await store.runDurableMutation(() => { - store.updateSettings({ theme: 'light' }) - return { value: undefined } - }) - expect(readState()).toMatchObject({ - settings: { theme: 'light' }, - workspaceSession: { activeTabId: 'after-failure' } - }) - await (await store.beginProfileMaintenance()).resume() - }) - - it('keeps changed storage fenced when recovering a known failure', async () => { - const { store, authority, peer, readState } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - store.updateSettings({ theme: 'dark' }) - vi.spyOn(authority, 'writeCompleteSerializedDomains').mockImplementationOnce(async () => { - const writer = peer() - writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) - writer.close() - throw new Error('disk refused') - }) - await expect(store.beginProfileMaintenance()).rejects.toThrow('disk refused') - await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( - 'finalized' - ) - expect(readState().peer).toEqual({ preserved: true }) - }) - - it('does not extend the maintenance checkpoint for unadmitted saves', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - store.updateSettings({ theme: 'dark' }) - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeCompleteSerializedDomains.bind(authority) - const checkpoint = vi - .spyOn(authority, 'writeCompleteSerializedDomains') - .mockImplementationOnce(async (domains) => { - started.resolve() - await release.promise - await write(domains) - }) - const pending = store.beginProfileMaintenance() - await started.promise - for (let terminalFontSize = 12; terminalFontSize < 32; terminalFontSize++) { - store.updateSettings({ terminalFontSize }) - } - release.resolve() - const maintenance = await pending - expect(checkpoint).toHaveBeenCalledOnce() - await maintenance.resume() - await store.flushPendingOrThrowAsync() - expect(readState().settings.terminalFontSize).toBe(31) - }) -}) diff --git a/src/main/persistence/loading-store/profile-state-maintenance.spec.ts b/src/main/persistence/loading-store/profile-state-maintenance.spec.ts new file mode 100644 index 00000000000..baacfec0d3f --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-maintenance.spec.ts @@ -0,0 +1,297 @@ +import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { ActiveViewPreference, getActiveViewPreferenceFile } from '../../active-view-preference' +import * as backupWorker from '../profile-state/profile-state-backup-worker' +import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' +import { + createSqliteMaintenanceFixture, + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' + +export function registerProfileStateMaintenanceTests(): void { + describe('profile maintenance admission', () => { + it('drains accepted writes and captures newer edits after blocking new durable operations', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const gate = maintenanceBarrier() + const started = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await gate.promise + await write(domains) + }) + const accepted = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: 'accepted' } + }) + await started.promise + const stopped = vi.spyOn(authority, 'close') + const paused = store.beginProfileMaintenance() + const refused = vi.fn(() => ({ value: undefined })) + await expect(store.runDurableMutation(refused)).rejects.toThrow('finalized') + expect(refused).not.toHaveBeenCalled() + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow('finalized') + expect(() => store.stageWorkspaceSessionBeforeUnload(store.getWorkspaceSession())).toThrow( + 'maintenance' + ) + store.updateSettings({ theme: 'light' }) + store.getWorkspaceSession().activeTabId = 'during-maintenance' + expect(stopped).not.toHaveBeenCalled() + gate.resolve() + await expect(accepted).resolves.toBe('accepted') + const maintenance = await paused + expect(stopped).toHaveBeenCalledOnce() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'during-maintenance' } + }) + store.updateSettings({ theme: 'dark' }) + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + await expect(maintenance.resume()).rejects.toThrow('finalization') + }) + + it('refuses re-admission after a competing write without adopting its revision', async () => { + const { store, peer, readState } = await createWorkerMaintenanceFixture() + store.updateSettings({ theme: 'dark' }) + const maintenance = await store.beginProfileMaintenance() + const writer = peer() + try { + writer.readSerializedState() + writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) + } finally { + writer.close() + } + await expect(maintenance.resume()).rejects.toThrow('Profile state revision changed') + const mutate = vi.fn(() => ({ value: undefined })) + await expect(store.runDurableMutation(mutate)).rejects.toThrow('finalized') + expect(mutate).not.toHaveBeenCalled() + expect(readState().peer).toEqual({ preserved: true }) + }) + + it.each(['maintenance', 'freeze', 'final'] as const)( + '%s waits for an admitted flush between SQL passes', + async (kind) => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const close = vi.spyOn(authority, 'close') + const paused = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + await new Promise((resolve) => setImmediate(resolve)) + expect(capture).not.toHaveBeenCalled() + expect(close).not.toHaveBeenCalled() + release.resolve() + await older + await paused + expect(close).toHaveBeenCalled() + expect(readState().settings.theme).toBe('light') + } + ) + + it.each(['maintenance', 'freeze', 'final'] as const)( + '%s waits for accepted retries after another flush reports a known failure', + async (kind) => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const accepted = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const failureStarted = maintenanceBarrier() + const fail = maintenanceBarrier() + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + failureStarted.resolve() + await fail.promise + throw new Error('disk refused') + }) + const failed = expect( + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ).rejects.toThrow('disk refused') + await failureStarted.promise + const close = vi.spyOn(authority, 'close') + const paused = + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : store.flushFinalOrThrowAsync() + fail.resolve() + await failed + await new Promise((resolve) => setImmediate(resolve)) + expect(close).not.toHaveBeenCalled() + release.resolve() + await accepted + await paused + expect(close).toHaveBeenCalledOnce() + expect(readState().settings.theme).toBe('light') + } + ) + + it('cancels a backup and waits for its worker to exit before releasing maintenance', async () => { + const { store, authority, databaseFile } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const canceled = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( + async (_job, options) => { + started.resolve() + await new Promise((resolve) => + options?.signal?.addEventListener('abort', () => resolve(), { once: true }) + ) + canceled.resolve() + await release.promise + throw new Error('backup aborted') + } + ) + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await started.promise + const close = vi.spyOn(authority, 'close') + const paused = store.beginProfileMaintenance() + let done = false + void paused.then(() => { + done = true + }) + await canceled.promise + expect(done).toBe(false) + expect(close).not.toHaveBeenCalled() + release.resolve() + await paused + expect(close).toHaveBeenCalledOnce() + expect(profileStateDatabaseBackups(databaseFile)).toHaveLength(0) + }) + + it('drains an accepted flush before rejecting canceled maintenance', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( + async () => { + started.resolve() + await release.promise + } + ) + store.updateSettings({ theme: 'dark' }) + const accepted = store.flushPendingOrThrowAsync() + await started.promise + store.updateSettings({ theme: 'light' }) + const close = vi.spyOn(authority, 'close') + const controller = new AbortController() + controller.abort() + const rejected = expect( + store.beginProfileMaintenance({ signal: controller.signal }) + ).rejects.toThrow('aborted') + await new Promise((resolve) => setImmediate(resolve)) + expect(close).not.toHaveBeenCalled() + release.resolve() + await accepted + await rejected + expect(close).toHaveBeenCalledOnce() + expect(readState().settings.theme).toBe('light') + }) + + it('joins an ongoing maintenance close at final shutdown without reopening or rewriting', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const close = authority.close.bind(authority) + vi.spyOn(authority, 'close').mockImplementationOnce(async () => { + started.resolve() + await release.promise + await close() + }) + const paused = store.beginProfileMaintenance() + await started.promise + const write = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const final = store.flushFinalOrThrowAsync() + release.resolve() + const maintenance = await paused + await final + expect(write).not.toHaveBeenCalled() + await expect(maintenance.resume()).rejects.toThrow('finalization') + }) + + it('quarantines a faulted worker only after closing it, without writing current memory', async () => { + const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() + const durable = readState() + store.updateSettings({ theme: durable.settings.theme === 'dark' ? 'light' : 'dark' }) + await authority.abort() + const result = await store.quarantineProfileStateDatabaseAsync(directory, 'worker-failure') + expect(result.copiedFiles.some((path) => path.endsWith('profile-state.db'))).toBe(true) + expect(readState()).toEqual(durable) + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + }) + + it('never provides a resume token after a faulted normal-maintenance attempt', async () => { + const { store, authority } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await authority.abort() + await expect(store.beginProfileMaintenance()).rejects.toThrow('aborted') + await expect(store.beginProfileMaintenance()).rejects.toThrow('already stopped') + }) + + it('pauses SQLite and preference timers, then persists edits after unchanged-source resume', async () => { + const { store, dataFile, authority } = createSqliteMaintenanceFixture() + const maintenance = await store.beginProfileMaintenance() + const before = authority.readSerializedState() + const preference = getActiveViewPreferenceFile(dataFile) + const preferenceBefore = readFileSync(preference) + vi.useFakeTimers() + try { + store.updateSettings({ theme: 'dark' }) + store.updateUI({ activeView: 'settings' }) + await vi.advanceTimersByTimeAsync(6_000) + expect(authority.readSerializedState()).toEqual(before) + expect(readFileSync(preference)).toEqual(preferenceBefore) + } finally { + vi.useRealTimers() + } + await maintenance.resume() + await store.flushPendingOrThrowAsync() + expect(JSON.parse(authority.readSerializedState() ?? '{}').settings.theme).toBe('dark') + expect(JSON.parse(readFileSync(preference, 'utf8')).activeView).toBe('settings') + }) + + it('refuses synchronous SQL resume if the source changed during maintenance', async () => { + const { store, databaseFile, profileId } = createSqliteMaintenanceFixture() + const maintenance = await store.beginProfileMaintenance() + const peer = new ProfileStateSqliteAuthority(databaseFile, profileId) + peer.readSerializedState() + peer.writeSerializedDomains([{ domain: 'peer', payload: 'true' }]) + peer.close() + await expect(maintenance.resume()).rejects.toThrow('Profile state revision changed') + await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') + expect(JSON.parse(peer.readSerializedState() ?? '{}').peer).toBe(true) + }) + }) +} diff --git a/src/main/persistence/loading-store/profile-state-maintenance.test.ts b/src/main/persistence/loading-store/profile-state-maintenance.test.ts deleted file mode 100644 index 62b785ce7f9..00000000000 --- a/src/main/persistence/loading-store/profile-state-maintenance.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { ProfileStateSqliteAuthority } from '../profile-state/profile-state-sqlite-authority' -import { readFileSync } from 'node:fs' -import { describe, expect, it, vi } from 'vitest' -import { ActiveViewPreference, getActiveViewPreferenceFile } from '../../active-view-preference' -import * as backupWorker from '../profile-state/profile-state-backup-worker' -import { profileStateDatabaseBackups } from '../profile-state/profile-state-backup-path' -import { - createSqliteMaintenanceFixture, - createWorkerMaintenanceFixture, - maintenanceBarrier -} from './profile-state-maintenance-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -describe('profile maintenance admission', () => { - it('drains accepted writes and captures newer edits after blocking new durable operations', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - const gate = maintenanceBarrier() - const started = maintenanceBarrier() - const write = authority.writeSerializedDomains.bind(authority) - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { - started.resolve() - await gate.promise - await write(domains) - }) - const accepted = store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: 'accepted' } - }) - await started.promise - const stopped = vi.spyOn(authority, 'close') - const paused = store.beginProfileMaintenance() - const refused = vi.fn(() => ({ value: undefined })) - await expect(store.runDurableMutation(refused)).rejects.toThrow('finalized') - expect(refused).not.toHaveBeenCalled() - await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') - await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow('finalized') - expect(() => store.stageWorkspaceSessionBeforeUnload(store.getWorkspaceSession())).toThrow( - 'maintenance' - ) - store.updateSettings({ theme: 'light' }) - store.getWorkspaceSession().activeTabId = 'during-maintenance' - expect(stopped).not.toHaveBeenCalled() - gate.resolve() - await expect(accepted).resolves.toBe('accepted') - const maintenance = await paused - expect(stopped).toHaveBeenCalledOnce() - expect(readState()).toMatchObject({ - settings: { theme: 'light' }, - workspaceSession: { activeTabId: 'during-maintenance' } - }) - store.updateSettings({ theme: 'dark' }) - await maintenance.resume() - await store.flushPendingOrThrowAsync() - expect(readState().settings.theme).toBe('dark') - await expect(maintenance.resume()).rejects.toThrow('finalization') - }) - - it('refuses re-admission after a competing write without adopting its revision', async () => { - const { store, peer, readState } = await createWorkerMaintenanceFixture() - store.updateSettings({ theme: 'dark' }) - const maintenance = await store.beginProfileMaintenance() - const writer = peer() - try { - writer.readSerializedState() - writer.writeSerializedDomains([{ domain: 'peer', payload: '{"preserved":true}' }]) - } finally { - writer.close() - } - await expect(maintenance.resume()).rejects.toThrow('Profile state revision changed') - const mutate = vi.fn(() => ({ value: undefined })) - await expect(store.runDurableMutation(mutate)).rejects.toThrow('finalized') - expect(mutate).not.toHaveBeenCalled() - expect(readState().peer).toEqual({ preserved: true }) - }) - - it.each(['maintenance', 'freeze', 'final'] as const)( - '%s waits for an admitted flush between SQL passes', - async (kind) => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( - async () => { - started.resolve() - await release.promise - } - ) - store.updateSettings({ theme: 'dark' }) - const older = store.flushPendingOrThrowAsync() - await started.promise - store.updateSettings({ theme: 'light' }) - const capture = vi.spyOn(authority, 'writeCompleteSerializedDomains') - const close = vi.spyOn(authority, 'close') - const paused = - kind === 'maintenance' - ? store.beginProfileMaintenance() - : kind === 'freeze' - ? store.freezeWritesAsync() - : store.flushFinalOrThrowAsync() - await new Promise((resolve) => setImmediate(resolve)) - expect(capture).not.toHaveBeenCalled() - expect(close).not.toHaveBeenCalled() - release.resolve() - await older - await paused - expect(close).toHaveBeenCalled() - expect(readState().settings.theme).toBe('light') - } - ) - - it.each(['maintenance', 'freeze', 'final'] as const)( - '%s waits for accepted retries after another flush reports a known failure', - async (kind) => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const started = maintenanceBarrier() - const release = maintenanceBarrier() - vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( - async () => { - started.resolve() - await release.promise - } - ) - store.updateSettings({ theme: 'dark' }) - const accepted = store.flushPendingOrThrowAsync() - await started.promise - store.updateSettings({ theme: 'light' }) - const failureStarted = maintenanceBarrier() - const fail = maintenanceBarrier() - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { - failureStarted.resolve() - await fail.promise - throw new Error('disk refused') - }) - const failed = expect( - store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - ).rejects.toThrow('disk refused') - await failureStarted.promise - const close = vi.spyOn(authority, 'close') - const paused = - kind === 'maintenance' - ? store.beginProfileMaintenance() - : kind === 'freeze' - ? store.freezeWritesAsync() - : store.flushFinalOrThrowAsync() - fail.resolve() - await failed - await new Promise((resolve) => setImmediate(resolve)) - expect(close).not.toHaveBeenCalled() - release.resolve() - await accepted - await paused - expect(close).toHaveBeenCalledOnce() - expect(readState().settings.theme).toBe('light') - } - ) - - it('cancels a backup and waits for its worker to exit before releasing maintenance', async () => { - const { store, authority, databaseFile } = await createWorkerMaintenanceFixture() - const started = maintenanceBarrier() - const canceled = maintenanceBarrier() - const release = maintenanceBarrier() - vi.spyOn(backupWorker, 'runProfileStateBackupWorker').mockImplementationOnce( - async (_job, options) => { - started.resolve() - await new Promise((resolve) => - options?.signal?.addEventListener('abort', () => resolve(), { once: true }) - ) - canceled.resolve() - await release.promise - throw new Error('backup aborted') - } - ) - await store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await started.promise - const close = vi.spyOn(authority, 'close') - const paused = store.beginProfileMaintenance() - let done = false - void paused.then(() => { - done = true - }) - await canceled.promise - expect(done).toBe(false) - expect(close).not.toHaveBeenCalled() - release.resolve() - await paused - expect(close).toHaveBeenCalledOnce() - expect(profileStateDatabaseBackups(databaseFile)).toHaveLength(0) - }) - - it('drains an accepted flush before rejecting canceled maintenance', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - vi.spyOn(ActiveViewPreference.prototype, 'flushPendingAsync').mockImplementationOnce( - async () => { - started.resolve() - await release.promise - } - ) - store.updateSettings({ theme: 'dark' }) - const accepted = store.flushPendingOrThrowAsync() - await started.promise - store.updateSettings({ theme: 'light' }) - const close = vi.spyOn(authority, 'close') - const controller = new AbortController() - controller.abort() - const rejected = expect( - store.beginProfileMaintenance({ signal: controller.signal }) - ).rejects.toThrow('aborted') - await new Promise((resolve) => setImmediate(resolve)) - expect(close).not.toHaveBeenCalled() - release.resolve() - await accepted - await rejected - expect(close).toHaveBeenCalledOnce() - expect(readState().settings.theme).toBe('light') - }) - - it('joins an ongoing maintenance close at final shutdown without reopening or rewriting', async () => { - const { store, authority } = await createWorkerMaintenanceFixture() - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const close = authority.close.bind(authority) - vi.spyOn(authority, 'close').mockImplementationOnce(async () => { - started.resolve() - await release.promise - await close() - }) - const paused = store.beginProfileMaintenance() - await started.promise - const write = vi.spyOn(authority, 'writeCompleteSerializedDomains') - const final = store.flushFinalOrThrowAsync() - release.resolve() - const maintenance = await paused - await final - expect(write).not.toHaveBeenCalled() - await expect(maintenance.resume()).rejects.toThrow('finalization') - }) - - it('quarantines a faulted worker only after closing it, without writing current memory', async () => { - const { store, authority, directory, readState } = await createWorkerMaintenanceFixture() - const durable = readState() - store.updateSettings({ theme: durable.settings.theme === 'dark' ? 'light' : 'dark' }) - await authority.abort() - const result = await store.quarantineProfileStateDatabaseAsync(directory, 'worker-failure') - expect(result.copiedFiles.some((path) => path.endsWith('profile-state.db'))).toBe(true) - expect(readState()).toEqual(durable) - await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') - }) - - it('never provides a resume token after a faulted normal-maintenance attempt', async () => { - const { store, authority } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await authority.abort() - await expect(store.beginProfileMaintenance()).rejects.toThrow('aborted') - await expect(store.beginProfileMaintenance()).rejects.toThrow('already stopped') - }) - - it('pauses SQLite and preference timers, then persists edits after unchanged-source resume', async () => { - const { store, dataFile, authority } = createSqliteMaintenanceFixture() - const maintenance = await store.beginProfileMaintenance() - const before = authority.readSerializedState() - const preference = getActiveViewPreferenceFile(dataFile) - const preferenceBefore = readFileSync(preference) - vi.useFakeTimers() - store.updateSettings({ theme: 'dark' }) - store.updateUI({ activeView: 'settings' }) - await vi.advanceTimersByTimeAsync(6_000) - expect(authority.readSerializedState()).toEqual(before) - expect(readFileSync(preference)).toEqual(preferenceBefore) - vi.useRealTimers() - await maintenance.resume() - await store.flushPendingOrThrowAsync() - expect(JSON.parse(authority.readSerializedState() ?? '{}').settings.theme).toBe('dark') - expect(JSON.parse(readFileSync(preference, 'utf8')).activeView).toBe('settings') - }) - - it('refuses synchronous SQL resume if the source changed during maintenance', async () => { - const { store, databaseFile, profileId } = createSqliteMaintenanceFixture() - const maintenance = await store.beginProfileMaintenance() - const peer = new ProfileStateSqliteAuthority(databaseFile, profileId) - peer.readSerializedState() - peer.writeSerializedDomains([{ domain: 'peer', payload: 'true' }]) - peer.close() - await expect(maintenance.resume()).rejects.toThrow('Profile state revision changed') - await expect(store.flushPendingOrThrowAsync()).rejects.toThrow('finalized') - expect(JSON.parse(peer.readSerializedState() ?? '{}').peer).toBe(true) - }) -}) diff --git a/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.spec.ts b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.spec.ts new file mode 100644 index 00000000000..ef6e98c257e --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.spec.ts @@ -0,0 +1,196 @@ +import { describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { + createWorkerMaintenanceFixture, + maintenanceBarrier +} from './profile-state-maintenance-fixture' +import type { Store } from './store' + +export function registerProfileStatePtyRetirementFinalizationTests(): void { + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'finalizing-retirement-tab', + leafId: TEST_LEAF_1, + ptyId: 'finalizing-retirement-pty', + incarnationId: 'finalizing-retirement-incarnation' + } + + function retire(store: Store, connectionId?: string) { + return retirePersistedStablePaneOwner( + store, + { ...binding, persistedIncarnationId: binding.incarnationId }, + binding.worktreeId, + connectionId + ) + } + + function assertNewSnapshotsRefused(store: Store) { + const session = store.getWorkspaceSession() + expect(() => store.setWorkspaceSession(session)).toThrow('blocking new terminal snapshot work') + expect(() => store.stageWorkspaceSessionBeforeUnload(session)).toThrow( + 'blocking new terminal snapshot work' + ) + } + + describe.each([ + ['running', undefined], + ['maintenance', undefined], + ['freeze', undefined], + ['final', undefined], + ['running', 'retirement-ssh'], + ['maintenance', 'retirement-ssh'], + ['freeze', 'retirement-ssh'], + ['final', 'retirement-ssh'] + ] as const)('admitted terminal retirement during %s on host %s', (kind, connectionId) => { + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + const persistedSession = ( + readState: Awaited>['readState'] + ) => { + const state = readState() + return hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession + } + const stop = (store: Store) => + kind === 'maintenance' + ? store.beginProfileMaintenance() + : kind === 'freeze' + ? store.freezeWritesAsync() + : kind === 'final' + ? store.flushFinalOrThrowAsync() + : Promise.resolve() + const assertSnapshotAdmission = (store: Store) => { + if (kind !== 'running') { + assertNewSnapshotsRefused(store) + } + } + + it('persists an accepted queued retirement while refusing new snapshots', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding, hostId) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + store.updateSettings({ theme: 'dark' }) + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await started.promise + const accepted = retire(store, connectionId).then( + (value) => ({ value }), + (error: unknown) => ({ error }) + ) + const stopping = stop(store) + assertSnapshotAdmission(store) + release.resolve() + await previous + await stopping + expect(await accepted).toEqual({ value: true }) + expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + assertSnapshotAdmission(store) + }) + + it('finishes a failed retirement rollback before closing its writer', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding, hostId) + const original = structuredClone(store.getWorkspaceSession(hostId)) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const failure = new ProfileStateWriterError( + 'test-disk-failure', + 'retirement disk refused', + 'known-failure' + ) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { + started.resolve() + await release.promise + throw failure + }) + const rejected = retire(store, connectionId).catch((error: unknown) => error) + await started.promise + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId] + ).toBeUndefined() + store.getWorkspaceSession(hostId).activeTabId = 'newer-active-tab' + const stopping = stop(store) + assertSnapshotAdmission(store) + release.resolve() + await stopping + expect(await rejected).toBe(failure) + expect(store.getWorkspaceSession(hostId)).toEqual({ + ...original, + activeTabId: 'newer-active-tab' + }) + expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toEqual( + original.terminalLayoutsByTabId[binding.tabId] + ) + assertSnapshotAdmission(store) + }) + }) + + describe.each(['mutate', 'rollback'] as const)('admitted %s scope', (phase) => { + it('preserves durable state and refuses later saves after a callback partially mutates then throws', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + const original = readState() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const failure = new Error('callback failed') + if (phase === 'rollback') { + vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + } + await expect( + store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + if (phase === 'mutate') { + throw failure + } + return { + value: undefined, + rollback: () => { + throw failure + } + } + }) + ).rejects.toBe(failure) + assertNewSnapshotsRefused(store) + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + await expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + expect(readState()).toEqual(original) + }) + }) + + it('rejects an already admitted final flush after a queued callback partially mutates then throws', async () => { + const { store, authority, readState } = await createWorkerMaintenanceFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const started = maintenanceBarrier() + const release = maintenanceBarrier() + const write = authority.writeSerializedDomains.bind(authority) + vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { + started.resolve() + await release.promise + await write(domains) + }) + store.updateSettings({ theme: 'dark' }) + const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await started.promise + const failure = new Error('partial edit failed') + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + throw failure + }) + const rejectedMutation = expect(mutation).rejects.toBe(failure) + const rejectedFinal = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) + release.resolve() + await Promise.all([previous, rejectedMutation, rejectedFinal]) + expect(readState().settings.theme).toBe('dark') + }) +} diff --git a/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts b/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts deleted file mode 100644 index 3fe64562997..00000000000 --- a/src/main/persistence/loading-store/profile-state-pty-retirement-finalization.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { toSshExecutionHostId } from '../../../shared/execution-host' -import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' -import { TEST_LEAF_1 } from '../../persistence-session-fixtures' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' -import { - createWorkerMaintenanceFixture, - maintenanceBarrier -} from './profile-state-maintenance-fixture' -import type { Store } from './store' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -const binding = { - worktreeId: 'repo-local::/fixture/local', - tabId: 'finalizing-retirement-tab', - leafId: TEST_LEAF_1, - ptyId: 'finalizing-retirement-pty', - incarnationId: 'finalizing-retirement-incarnation' -} - -function retire(store: Store, connectionId?: string) { - return retirePersistedStablePaneOwner( - store, - { ...binding, persistedIncarnationId: binding.incarnationId }, - binding.worktreeId, - connectionId - ) -} - -function assertNewSnapshotsRefused(store: Store) { - const session = store.getWorkspaceSession() - expect(() => store.setWorkspaceSession(session)).toThrow('blocking new terminal snapshot work') - expect(() => store.stageWorkspaceSessionBeforeUnload(session)).toThrow( - 'blocking new terminal snapshot work' - ) -} - -describe.each([ - ['running', undefined], - ['maintenance', undefined], - ['freeze', undefined], - ['final', undefined], - ['running', 'retirement-ssh'], - ['maintenance', 'retirement-ssh'], - ['freeze', 'retirement-ssh'], - ['final', 'retirement-ssh'] -] as const)('admitted terminal retirement during %s on host %s', (kind, connectionId) => { - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - const persistedSession = ( - readState: Awaited>['readState'] - ) => { - const state = readState() - return hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession - } - const stop = (store: Store) => - kind === 'maintenance' - ? store.beginProfileMaintenance() - : kind === 'freeze' - ? store.freezeWritesAsync() - : kind === 'final' - ? store.flushFinalOrThrowAsync() - : Promise.resolve() - const assertSnapshotAdmission = (store: Store) => { - if (kind !== 'running') { - assertNewSnapshotsRefused(store) - } - } - - it('persists an accepted queued retirement while refusing new snapshots', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await store.persistPtyBinding(binding, hostId) - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeSerializedDomains.bind(authority) - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { - started.resolve() - await release.promise - await write(domains) - }) - store.updateSettings({ theme: 'dark' }) - const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await started.promise - const accepted = retire(store, connectionId).then( - (value) => ({ value }), - (error: unknown) => ({ error }) - ) - const stopping = stop(store) - assertSnapshotAdmission(store) - release.resolve() - await previous - await stopping - expect(await accepted).toEqual({ value: true }) - expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() - assertSnapshotAdmission(store) - }) - - it('finishes a failed retirement rollback before closing its writer', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await store.persistPtyBinding(binding, hostId) - const original = structuredClone(store.getWorkspaceSession(hostId)) - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const failure = new ProfileStateWriterError( - 'test-disk-failure', - 'retirement disk refused', - 'known-failure' - ) - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async () => { - started.resolve() - await release.promise - throw failure - }) - const rejected = retire(store, connectionId).catch((error: unknown) => error) - await started.promise - expect(store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId]).toBeUndefined() - store.getWorkspaceSession(hostId).activeTabId = 'newer-active-tab' - const stopping = stop(store) - assertSnapshotAdmission(store) - release.resolve() - await stopping - expect(await rejected).toBe(failure) - expect(store.getWorkspaceSession(hostId)).toEqual({ - ...original, - activeTabId: 'newer-active-tab' - }) - expect(persistedSession(readState).terminalLayoutsByTabId[binding.tabId]).toEqual( - original.terminalLayoutsByTabId[binding.tabId] - ) - assertSnapshotAdmission(store) - }) -}) - -describe.each(['mutate', 'rollback'] as const)('admitted %s scope', (phase) => { - it('preserves durable state and refuses later saves after a callback partially mutates then throws', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - const original = readState() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const failure = new Error('callback failed') - if (phase === 'rollback') { - vi.spyOn(authority, 'writeSerializedDomains').mockRejectedValueOnce(new Error('disk refused')) - } - await expect( - store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - if (phase === 'mutate') { - throw failure - } - return { - value: undefined, - rollback: () => { - throw failure - } - } - }) - ).rejects.toBe(failure) - assertNewSnapshotsRefused(store) - await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( - 'finalized' - ) - await expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) - expect(readState()).toEqual(original) - }) -}) - -it('rejects an already admitted final flush after a queued callback partially mutates then throws', async () => { - const { store, authority, readState } = await createWorkerMaintenanceFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const started = maintenanceBarrier() - const release = maintenanceBarrier() - const write = authority.writeSerializedDomains.bind(authority) - vi.spyOn(authority, 'writeSerializedDomains').mockImplementationOnce(async (domains) => { - started.resolve() - await release.promise - await write(domains) - }) - store.updateSettings({ theme: 'dark' }) - const previous = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await started.promise - const failure = new Error('partial edit failed') - const mutation = store.runDurableMutation(() => { - store.updateSettings({ theme: 'light' }) - throw failure - }) - const rejectedMutation = expect(mutation).rejects.toBe(failure) - const rejectedFinal = expect(store.flushFinalOrThrowAsync()).rejects.toBe(failure) - release.resolve() - await Promise.all([previous, rejectedMutation, rejectedFinal]) - expect(readState().settings.theme).toBe('dark') -}) diff --git a/src/main/persistence/loading-store/profile-state-worker-coordination.spec.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.spec.ts new file mode 100644 index 00000000000..f548d98d265 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-coordination.spec.ts @@ -0,0 +1,329 @@ +import { describe, expect, it, vi } from 'vitest' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' + +export function registerProfileStateWorkerCoordinationTests(): void { + describe('worker-owned Store writes', () => { + it('consumes the debounce timer and avoids full checkpoints after a selective durable write', async () => { + const { store, authority, readState } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const selective = vi.spyOn(authority, 'writeSerializedDomains') + vi.useFakeTimers() + try { + await store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await vi.advanceTimersByTimeAsync(6_000) + await store.waitForPendingWrite() + expect(selective).toHaveBeenCalledOnce() + expect(full).not.toHaveBeenCalled() + expect(readState().settings.theme).toBe('dark') + } finally { + vi.useRealTimers() + } + }) + + it('still captures direct durable mutations that do not identify dirty domains', async () => { + const { store, readState } = await fixture() + await store.runDurableMutation(() => { + store.getWorkspaceSession().activeTabId = 'direct-mutation' + return { value: undefined } + }) + expect(readState().workspaceSession.activeTabId).toBe('direct-mutation') + }) + + it('skips a debounce callback already queued behind the write that consumed its changes', async () => { + const { store, authority } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const gate = authority.pause() + vi.useFakeTimers() + try { + const durable = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await durable + await store.waitForPendingWrite() + expect(full).not.toHaveBeenCalled() + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('handles a rejected debounced save and retains it for an explicit retry', async () => { + const { store, authority, readState } = await fixture() + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + vi.useFakeTimers() + try { + store.updateSettings({ theme: 'dark' }) + await vi.advanceTimersByTimeAsync(1000) + await gate.started.promise + gate.finish.reject(new Error('disk refused')) + await store.waitForPendingWrite() + expect(log).toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('dark') + }) + + it('refuses an export when serialization invalidates its full checkpoint', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const publish = vi.spyOn(authority, 'writeLatestJsonExport') + const session = store.getWorkspaceSession() + Object.defineProperty(session, 'toJSON', { + configurable: true, + value: () => { + store.updateSettings({ theme: 'light' }) + return { ...session } + } + }) + try { + await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow( + 'changed while preparing its export' + ) + expect(publish).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(session, 'toJSON') + } + await store.writeLatestProfileStateJsonExportAsync() + expect(readState().settings.theme).toBe('light') + expect(publish).toHaveBeenCalledOnce() + }) + + it('retains selective write intent when a toJSON hook changes the captured generation', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const selective = vi.spyOn(authority, 'writeSerializedDomains') + const serialize = vi.fn((key: string) => { + expect(key).toBe('workspaceSession') + store.updateSettings({ theme: 'light' }) + return { ...store.getWorkspaceSession() } + }) + try { + await expect( + store.runDurableMutation(() => { + store.patchWorkspaceSession({ activeTabId: 'captured-tab' }) + Object.defineProperty(store.getWorkspaceSession(), 'toJSON', { + configurable: true, + value: serialize + }) + return { value: undefined } + }) + ).rejects.toThrow('changed while preparing its durable snapshot') + expect(serialize).toHaveBeenCalledOnce() + expect(selective).not.toHaveBeenCalled() + } finally { + Reflect.deleteProperty(store.getWorkspaceSession(), 'toJSON') + } + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'captured-tab' } + }) + expect(selective).toHaveBeenCalledOnce() + }) + + it('preserves canonical domain bytes and revisions for raw JSON and proxy-valued hooks', async () => { + if (!('rawJSON' in JSON) || typeof JSON.rawJSON !== 'function') { + throw new Error('This test requires native JSON.rawJSON support') + } + const overflow: unknown = JSON.rawJSON('1e999') + const escaped: unknown = JSON.rawJSON('"\\u0061"') + const { store, authority, readState } = await fixture() + store.patchWorkspaceSession({ activeTabId: 'pending-tab' }) + const session = store.getWorkspaceSession() + const keyOrder = new Proxy({ 1: 'one', 2: 'two' }, { ownKeys: () => ['2', '1'] }) + const serialize = vi.fn(() => ({ ...session, activeTabId: escaped, overflow, keyOrder })) + Object.defineProperty(session, 'toJSON', { configurable: true, value: serialize }) + const expected = JSON.stringify({ + ...session, + activeTabId: 'a', + overflow: null, + keyOrder: { + 1: 'one', + 2: 'two' + } + }) + try { + await store.flushPendingOrThrowAsync() + expect(authority.captures.at(-1)).toContainEqual({ + domain: 'workspaceSession', + payload: expected + }) + expect(readState().workspaceSession).toMatchObject({ activeTabId: 'a', overflow: null }) + expect(serialize).toHaveBeenCalledOnce() + const revision = authority.inner.revision + store.setWorkspaceSession(session) + await store.flushPendingOrThrowAsync() + expect(authority.inner.revision).toBe(revision) + expect(serialize).toHaveBeenCalledTimes(2) + } finally { + Reflect.deleteProperty(session, 'toJSON') + } + }) + + it('retains a newer edit after an older write is acknowledged', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ theme: 'light' }) + gate.finish.resolve() + await first + expect(readState().settings.theme).toBe('dark') + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('light') + }) + + it('merges a failed older write with dirty state added while it was in flight', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const rejected = expect(first).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'newer-tab' + store.setWorkspaceSession(store.getWorkspaceSession()) + gate.finish.reject(new Error('disk refused')) + await rejected + await store.flushPendingOrThrowAsync() + expect(readState()).toMatchObject({ + settings: { theme: 'dark' }, + workspaceSession: { activeTabId: 'newer-tab' } + }) + }) + + it('captures a full checkpoint after an older save even without a new generation', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-tab' + const final = store.flushAsync() + gate.finish.resolve() + await first + await final + expect(readState().workspaceSession.activeTabId).toBe('getter-only-tab') + expect(authority.captures.at(-1)?.some(({ domain }) => domain === 'workspaceSession')).toBe( + true + ) + }) + + it('reserves ordering before an exact mutation changes live state', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const mutate = vi.fn(() => { + store.updateSettings({ theme: 'light' }) + return { value: 'durable' } + }) + const exact = store.runDurableMutation(mutate) + await Promise.resolve() + expect(mutate).not.toHaveBeenCalled() + expect(store.getSettings().theme).toBe('dark') + gate.finish.resolve() + await first + await expect(exact).resolves.toBe('durable') + expect(readState().settings.theme).toBe('light') + }) + + it('retains dirty intent while many durability waiters share an active snapshot', async () => { + const { store, authority, readState } = await fixture() + const fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + const gate = authority.pause() + store.updateSettings({ terminalFontSize: 12 }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const waiters: Promise[] = [first] + for (let size = 13; size <= 32; size++) { + store.updateSettings({ terminalFontSize: size }) + waiters.push(store.flushPendingOrThrowAsync({ drainToStableGeneration: false })) + } + await Promise.resolve() + expect(authority.captures).toHaveLength(1) + gate.finish.resolve() + await Promise.all(waiters) + expect(readState().settings.terminalFontSize).toBe(32) + expect(fullCapture).toHaveBeenCalledOnce() + expect(authority.captures).toHaveLength(2) + }) + + it('cancels a queued checkpoint without aborting the preceding writer or losing edits', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const abortWriter = vi.spyOn(authority, 'abort') + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const controller = new AbortController() + store.updateSettings({ theme: 'light' }) + const canceled = store.flushPendingOrThrowAsync({ signal: controller.signal }) + const refused = expect(canceled).rejects.toThrow('aborted') + controller.abort() + gate.finish.resolve() + await first + await refused + expect(abortWriter).not.toHaveBeenCalled() + await store.flushPendingOrThrowAsync() + expect(readState().settings.theme).toBe('light') + }) + + it.each(['known-failure', 'indeterminate'] as const)( + 'only rolls back a mutation with a known failure (%s)', + async (outcome) => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rollback = vi.fn() + const exact = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined, rollback } + }) + const rejected = expect(exact).rejects.toThrow('write failed') + await gate.started.promise + gate.finish.reject(new ProfileStateWriterError('test', 'write failed', outcome)) + await rejected + expect(rollback).toHaveBeenCalledTimes(outcome === 'known-failure' ? 1 : 0) + } + ) + + it('awaits accepted operations before closing and refuses new exact mutations', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + const first = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + const closing = store.freezeWritesAsync() + await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( + 'finalized' + ) + expect(authority.close).not.toHaveBeenCalled() + gate.finish.resolve() + await first + await closing + expect(authority.close).toHaveBeenCalledTimes(1) + expect(readState().settings.theme).toBe('dark') + }) + }) +} diff --git a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts b/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts deleted file mode 100644 index 7144abe0789..00000000000 --- a/src/main/persistence/loading-store/profile-state-worker-coordination.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' -import { fixture } from './profile-state-delayed-authority-fixture' -import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -describe('worker-owned Store writes', () => { - it('consumes the debounce timer and avoids full checkpoints after a selective durable write', async () => { - const { store, authority, readState } = await fixture() - const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') - const selective = vi.spyOn(authority, 'writeSerializedDomains') - vi.useFakeTimers() - try { - await store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await vi.advanceTimersByTimeAsync(6_000) - await store.waitForPendingWrite() - expect(selective).toHaveBeenCalledOnce() - expect(full).not.toHaveBeenCalled() - expect(readState().settings.theme).toBe('dark') - } finally { - vi.useRealTimers() - } - }) - - it('still captures direct durable mutations that do not identify dirty domains', async () => { - const { store, readState } = await fixture() - await store.runDurableMutation(() => { - store.getWorkspaceSession().activeTabId = 'direct-mutation' - return { value: undefined } - }) - expect(readState().workspaceSession.activeTabId).toBe('direct-mutation') - }) - - it('skips a debounce callback already queued behind the write that consumed its changes', async () => { - const { store, authority } = await fixture() - const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') - const gate = authority.pause() - vi.useFakeTimers() - try { - const durable = store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await gate.started.promise - await vi.advanceTimersByTimeAsync(1_000) - gate.finish.resolve() - await durable - await store.waitForPendingWrite() - expect(full).not.toHaveBeenCalled() - } finally { - gate.finish.resolve() - vi.useRealTimers() - } - }) - - it('handles a rejected debounced save and retains it for an explicit retry', async () => { - const { store, authority, readState } = await fixture() - const log = vi.spyOn(console, 'error').mockImplementation(() => {}) - const gate = authority.pause() - vi.useFakeTimers() - try { - store.updateSettings({ theme: 'dark' }) - await vi.advanceTimersByTimeAsync(1000) - await gate.started.promise - gate.finish.reject(new Error('disk refused')) - await store.waitForPendingWrite() - expect(log).toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - await store.flushPendingOrThrowAsync() - expect(readState().settings.theme).toBe('dark') - }) - - it('refuses an export when serialization invalidates its full checkpoint', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const publish = vi.spyOn(authority, 'writeLatestJsonExport') - const session = store.getWorkspaceSession() - Object.defineProperty(session, 'toJSON', { - configurable: true, - value: () => { - store.updateSettings({ theme: 'light' }) - return { ...session } - } - }) - try { - await expect(store.writeLatestProfileStateJsonExportAsync()).rejects.toThrow( - 'changed while preparing its export' - ) - expect(publish).not.toHaveBeenCalled() - } finally { - Reflect.deleteProperty(session, 'toJSON') - } - await store.writeLatestProfileStateJsonExportAsync() - expect(readState().settings.theme).toBe('light') - expect(publish).toHaveBeenCalledOnce() - }) - - it('retains selective write intent when a toJSON hook changes the captured generation', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const selective = vi.spyOn(authority, 'writeSerializedDomains') - const serialize = vi.fn((key: string) => { - expect(key).toBe('workspaceSession') - store.updateSettings({ theme: 'light' }) - return { ...store.getWorkspaceSession() } - }) - try { - await expect( - store.runDurableMutation(() => { - store.patchWorkspaceSession({ activeTabId: 'captured-tab' }) - Object.defineProperty(store.getWorkspaceSession(), 'toJSON', { - configurable: true, - value: serialize - }) - return { value: undefined } - }) - ).rejects.toThrow('changed while preparing its durable snapshot') - expect(serialize).toHaveBeenCalledOnce() - expect(selective).not.toHaveBeenCalled() - } finally { - Reflect.deleteProperty(store.getWorkspaceSession(), 'toJSON') - } - await store.flushPendingOrThrowAsync() - expect(readState()).toMatchObject({ - settings: { theme: 'light' }, - workspaceSession: { activeTabId: 'captured-tab' } - }) - expect(selective).toHaveBeenCalledOnce() - }) - - it('preserves canonical domain bytes and revisions for raw JSON and proxy-valued hooks', async () => { - if (!('rawJSON' in JSON) || typeof JSON.rawJSON !== 'function') { - throw new Error('This test requires native JSON.rawJSON support') - } - const overflow: unknown = JSON.rawJSON('1e999') - const escaped: unknown = JSON.rawJSON('"\\u0061"') - const { store, authority, readState } = await fixture() - store.patchWorkspaceSession({ activeTabId: 'pending-tab' }) - const session = store.getWorkspaceSession() - const keyOrder = new Proxy({ 1: 'one', 2: 'two' }, { ownKeys: () => ['2', '1'] }) - const serialize = vi.fn(() => ({ ...session, activeTabId: escaped, overflow, keyOrder })) - Object.defineProperty(session, 'toJSON', { configurable: true, value: serialize }) - const expected = JSON.stringify({ - ...session, - activeTabId: 'a', - overflow: null, - keyOrder: { - 1: 'one', - 2: 'two' - } - }) - try { - await store.flushPendingOrThrowAsync() - expect(authority.captures.at(-1)).toContainEqual({ - domain: 'workspaceSession', - payload: expected - }) - expect(readState().workspaceSession).toMatchObject({ activeTabId: 'a', overflow: null }) - expect(serialize).toHaveBeenCalledOnce() - const revision = authority.inner.revision - store.setWorkspaceSession(session) - await store.flushPendingOrThrowAsync() - expect(authority.inner.revision).toBe(revision) - expect(serialize).toHaveBeenCalledTimes(2) - } finally { - Reflect.deleteProperty(session, 'toJSON') - } - }) - - it('retains a newer edit after an older write is acknowledged', async () => { - const { store, authority, readState } = await fixture() - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - store.updateSettings({ theme: 'light' }) - gate.finish.resolve() - await first - expect(readState().settings.theme).toBe('dark') - await store.flushPendingOrThrowAsync() - expect(readState().settings.theme).toBe('light') - }) - - it('merges a failed older write with dirty state added while it was in flight', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - const rejected = expect(first).rejects.toThrow('disk refused') - await gate.started.promise - store.getWorkspaceSession().activeTabId = 'newer-tab' - store.setWorkspaceSession(store.getWorkspaceSession()) - gate.finish.reject(new Error('disk refused')) - await rejected - await store.flushPendingOrThrowAsync() - expect(readState()).toMatchObject({ - settings: { theme: 'dark' }, - workspaceSession: { activeTabId: 'newer-tab' } - }) - }) - - it('captures a full checkpoint after an older save even without a new generation', async () => { - const { store, authority, readState } = await fixture() - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - store.getWorkspaceSession().activeTabId = 'getter-only-tab' - const final = store.flushAsync() - gate.finish.resolve() - await first - await final - expect(readState().workspaceSession.activeTabId).toBe('getter-only-tab') - expect(authority.captures.at(-1)?.some(({ domain }) => domain === 'workspaceSession')).toBe( - true - ) - }) - - it('reserves ordering before an exact mutation changes live state', async () => { - const { store, authority, readState } = await fixture() - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - const mutate = vi.fn(() => { - store.updateSettings({ theme: 'light' }) - return { value: 'durable' } - }) - const exact = store.runDurableMutation(mutate) - await Promise.resolve() - expect(mutate).not.toHaveBeenCalled() - expect(store.getSettings().theme).toBe('dark') - gate.finish.resolve() - await first - await expect(exact).resolves.toBe('durable') - expect(readState().settings.theme).toBe('light') - }) - - it('retains dirty intent while many durability waiters share an active snapshot', async () => { - const { store, authority, readState } = await fixture() - const fullCapture = vi.spyOn( - StateSerializationSecretHandlingOperations.prototype, - 'buildStateToSave' - ) - const gate = authority.pause() - store.updateSettings({ terminalFontSize: 12 }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - const waiters: Promise[] = [first] - for (let size = 13; size <= 32; size++) { - store.updateSettings({ terminalFontSize: size }) - waiters.push(store.flushPendingOrThrowAsync({ drainToStableGeneration: false })) - } - await Promise.resolve() - expect(authority.captures).toHaveLength(1) - gate.finish.resolve() - await Promise.all(waiters) - expect(readState().settings.terminalFontSize).toBe(32) - expect(fullCapture).toHaveBeenCalledOnce() - expect(authority.captures).toHaveLength(2) - }) - - it('cancels a queued checkpoint without aborting the preceding writer or losing edits', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const abortWriter = vi.spyOn(authority, 'abort') - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - const controller = new AbortController() - store.updateSettings({ theme: 'light' }) - const canceled = store.flushPendingOrThrowAsync({ signal: controller.signal }) - const refused = expect(canceled).rejects.toThrow('aborted') - controller.abort() - gate.finish.resolve() - await first - await refused - expect(abortWriter).not.toHaveBeenCalled() - await store.flushPendingOrThrowAsync() - expect(readState().settings.theme).toBe('light') - }) - - it.each(['known-failure', 'indeterminate'] as const)( - 'only rolls back a mutation with a known failure (%s)', - async (outcome) => { - const { store, authority } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const gate = authority.pause() - const rollback = vi.fn() - const exact = store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined, rollback } - }) - const rejected = expect(exact).rejects.toThrow('write failed') - await gate.started.promise - gate.finish.reject(new ProfileStateWriterError('test', 'write failed', outcome)) - await rejected - expect(rollback).toHaveBeenCalledTimes(outcome === 'known-failure' ? 1 : 0) - } - ) - - it('awaits accepted operations before closing and refuses new exact mutations', async () => { - const { store, authority, readState } = await fixture() - const gate = authority.pause() - const first = store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await gate.started.promise - const closing = store.freezeWritesAsync() - await expect(store.runDurableMutation(() => ({ value: undefined }))).rejects.toThrow( - 'finalized' - ) - expect(authority.close).not.toHaveBeenCalled() - gate.finish.resolve() - await first - await closing - expect(authority.close).toHaveBeenCalledTimes(1) - expect(readState().settings.theme).toBe('dark') - }) -}) diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.spec.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.spec.ts new file mode 100644 index 00000000000..888dab9df20 --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-worker-secret-retention.spec.ts @@ -0,0 +1,139 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getSecretStore, setSecretStore } from '../../../shared/secret-store' +import { fixture } from './profile-state-delayed-authority-fixture' + +export function registerProfileStateWorkerSecretRetentionTests(): void { + let encryptionAvailable = true + let previousSecretStore: ReturnType + const ciphertext = (plaintext: string) => Buffer.from(`sealed:${plaintext}`).toString('base64') + + beforeEach(() => { + encryptionAvailable = true + previousSecretStore = getSecretStore() + setSecretStore({ + isEncryptionAvailable: () => encryptionAvailable, + encryptString: (value) => Buffer.from(`sealed:${value}`), + decryptString: (value) => value.toString().slice('sealed:'.length), + describeProtectionGap: () => null + }) + }) + afterEach(() => setSecretStore(previousSecretStore)) + + describe.each(['opencodeSessionCookie'] as const)( + 'Store %s retention across worker acknowledgements', + (setting) => { + it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ [setting]: '' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe('') + expect(store.getSettings()[setting]).toBe('') + }) + + it('retains confirmed ciphertext until a newer secret can be encrypted', async () => { + const { store, authority, readState } = await fixture() + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'in-flight' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ [setting]: 'newer' }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('in-flight')) + expect(store.getSettings()[setting]).toBe('newer') + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('newer')) + }) + + it('retains the earlier ciphertext after a failed write and retries newer plaintext', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.updateSettings({ [setting]: 'durable' }) + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + store.updateSettings({ [setting]: 'failed' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const failure = expect(write).rejects.toThrow('disk refused') + await gate.started.promise + store.updateSettings({ [setting]: 'newer' }) + encryptionAvailable = false + gate.finish.reject(new Error('disk refused')) + await failure + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('durable')) + encryptionAvailable = true + store.updateSettings({ theme: 'dark' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings[setting]).toBe(ciphertext('newer')) + }) + } + ) + + describe('worker protected settings serialization', () => { + it.each(['selective', 'complete'] as const)( + 'seals the cookie and retains the legacy Go key privately in a %s SQLite write', + async (mode) => { + const { store, authority, readState } = await fixture(ciphertext('legacy-go-key')) + const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') + const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const secrets = { + opencodeSessionCookie: 'cookie-only-plaintext', + opencodeGoApiKey: 'retired-write-is-ignored' + } + store.updateSettings(secrets) + if (mode === 'complete') { + store.updateOnboarding({ outcome: 'completed' }) + } + await store.flushPendingOrThrowAsync() + expect(selectiveWrite).toHaveBeenCalledTimes(mode === 'selective' ? 1 : 0) + expect(completeWrite).toHaveBeenCalledTimes(mode === 'complete' ? 1 : 0) + const persisted = readState() + expect(persisted.settings).toMatchObject({ + opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie), + opencodeGoApiKey: ciphertext('legacy-go-key') + }) + for (const plaintext of Object.values(secrets)) { + expect(JSON.stringify(persisted)).not.toContain(plaintext) + } + expect(store.getSettings().opencodeSessionCookie).toBe(secrets.opencodeSessionCookie) + expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey') + } + ) + }) + + it('does not restore the migrated legacy Go ciphertext from a pending worker acknowledgement', async () => { + const { store, authority, readState } = await fixture(ciphertext('legacy-go-key')) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const save = vi.fn() + store.migrateLegacyOpenCodeGoApiKey({ has: () => false, read: () => null, save }) + encryptionAvailable = false + gate.finish.resolve() + await write + await store.flushPendingOrThrowAsync() + expect(save).toHaveBeenCalledExactlyOnceWith('legacy-go-key') + expect(readState().settings).not.toHaveProperty('opencodeGoApiKey') + expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey') + encryptionAvailable = true + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync() + expect(readState().settings).not.toHaveProperty('opencodeGoApiKey') + }) +} diff --git a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts b/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts deleted file mode 100644 index 648319f59f0..00000000000 --- a/src/main/persistence/loading-store/profile-state-worker-secret-retention.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { getSecretStore, setSecretStore } from '../../../shared/secret-store' -import { fixture } from './profile-state-delayed-authority-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -let encryptionAvailable = true -let previousSecretStore: ReturnType -const ciphertext = (plaintext: string) => Buffer.from(`sealed:${plaintext}`).toString('base64') - -beforeEach(() => { - encryptionAvailable = true - previousSecretStore = getSecretStore() - setSecretStore({ - isEncryptionAvailable: () => encryptionAvailable, - encryptString: (value) => Buffer.from(`sealed:${value}`), - decryptString: (value) => value.toString().slice('sealed:'.length), - describeProtectionGap: () => null - }) -}) -afterEach(() => setSecretStore(previousSecretStore)) - -describe.each(['opencodeSessionCookie'] as const)( - 'Store %s retention across worker acknowledgements', - (setting) => { - it('does not restore ciphertext cleared while its commit acknowledgement was pending', async () => { - const { store, authority, readState } = await fixture() - store.updateSettings({ [setting]: 'durable' }) - await store.flushPendingOrThrowAsync() - const gate = authority.pause() - store.updateSettings({ [setting]: 'in-flight' }) - const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - store.updateSettings({ [setting]: '' }) - encryptionAvailable = false - gate.finish.resolve() - await write - await store.flushPendingOrThrowAsync() - expect(readState().settings[setting]).toBe('') - expect(store.getSettings()[setting]).toBe('') - }) - - it('retains confirmed ciphertext until a newer secret can be encrypted', async () => { - const { store, authority, readState } = await fixture() - store.updateSettings({ [setting]: 'durable' }) - await store.flushPendingOrThrowAsync() - const gate = authority.pause() - store.updateSettings({ [setting]: 'in-flight' }) - const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - store.updateSettings({ [setting]: 'newer' }) - encryptionAvailable = false - gate.finish.resolve() - await write - await store.flushPendingOrThrowAsync() - expect(readState().settings[setting]).toBe(ciphertext('in-flight')) - expect(store.getSettings()[setting]).toBe('newer') - encryptionAvailable = true - store.updateSettings({ theme: 'dark' }) - await store.flushPendingOrThrowAsync() - expect(readState().settings[setting]).toBe(ciphertext('newer')) - }) - - it('retains the earlier ciphertext after a failed write and retries newer plaintext', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - store.updateSettings({ [setting]: 'durable' }) - await store.flushPendingOrThrowAsync() - const gate = authority.pause() - store.updateSettings({ [setting]: 'failed' }) - const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - const failure = expect(write).rejects.toThrow('disk refused') - await gate.started.promise - store.updateSettings({ [setting]: 'newer' }) - encryptionAvailable = false - gate.finish.reject(new Error('disk refused')) - await failure - await store.flushPendingOrThrowAsync() - expect(readState().settings[setting]).toBe(ciphertext('durable')) - encryptionAvailable = true - store.updateSettings({ theme: 'dark' }) - await store.flushPendingOrThrowAsync() - expect(readState().settings[setting]).toBe(ciphertext('newer')) - }) - } -) - -describe('worker protected settings serialization', () => { - it.each(['selective', 'complete'] as const)( - 'seals the cookie and retains the legacy Go key privately in a %s SQLite write', - async (mode) => { - const { store, authority, readState } = await fixture(ciphertext('legacy-go-key')) - const selectiveWrite = vi.spyOn(authority, 'writeSerializedDomains') - const completeWrite = vi.spyOn(authority, 'writeCompleteSerializedDomains') - const secrets = { - opencodeSessionCookie: 'cookie-only-plaintext', - opencodeGoApiKey: 'retired-write-is-ignored' - } - store.updateSettings(secrets) - if (mode === 'complete') { - store.updateOnboarding({ outcome: 'completed' }) - } - await store.flushPendingOrThrowAsync() - expect(selectiveWrite).toHaveBeenCalledTimes(mode === 'selective' ? 1 : 0) - expect(completeWrite).toHaveBeenCalledTimes(mode === 'complete' ? 1 : 0) - const persisted = readState() - expect(persisted.settings).toMatchObject({ - opencodeSessionCookie: ciphertext(secrets.opencodeSessionCookie), - opencodeGoApiKey: ciphertext('legacy-go-key') - }) - for (const plaintext of Object.values(secrets)) { - expect(JSON.stringify(persisted)).not.toContain(plaintext) - } - expect(store.getSettings().opencodeSessionCookie).toBe(secrets.opencodeSessionCookie) - expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey') - } - ) -}) - -it('does not restore the migrated legacy Go ciphertext from a pending worker acknowledgement', async () => { - const { store, authority, readState } = await fixture(ciphertext('legacy-go-key')) - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const write = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - const save = vi.fn() - store.migrateLegacyOpenCodeGoApiKey({ has: () => false, read: () => null, save }) - encryptionAvailable = false - gate.finish.resolve() - await write - await store.flushPendingOrThrowAsync() - expect(save).toHaveBeenCalledExactlyOnceWith('legacy-go-key') - expect(readState().settings).not.toHaveProperty('opencodeGoApiKey') - expect(store.getSettings()).not.toHaveProperty('opencodeGoApiKey') - encryptionAvailable = true - store.updateSettings({ theme: 'light' }) - await store.flushPendingOrThrowAsync() - expect(readState().settings).not.toHaveProperty('opencodeGoApiKey') -}) diff --git a/src/main/persistence/loading-store/profile-state-write-batching.spec.ts b/src/main/persistence/loading-store/profile-state-write-batching.spec.ts new file mode 100644 index 00000000000..44d181913fb --- /dev/null +++ b/src/main/persistence/loading-store/profile-state-write-batching.spec.ts @@ -0,0 +1,244 @@ +import { describe, expect, it, vi } from 'vitest' +import { deferred, fixture } from './profile-state-delayed-authority-fixture' +import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' +import * as composition from './store-domain-composition' +import type { StoreRuntimeState } from './store-runtime-state' +import { ProfileStateRevisionConflictError } from '../profile-state/profile-state-document-validation' + +export function registerProfileStateWriteBatchingTests(): void { + async function snapshotFixture() { + let captured: StoreRuntimeState | undefined + const createDomains = composition.createStoreDomains + vi.spyOn(composition, 'createStoreDomains').mockImplementationOnce((runtime) => { + captured = runtime + return createDomains(runtime) + }) + const state = await fixture() + if (!captured) { + throw new Error('Store runtime was not initialized') + } + return { ...state, runtime: captured } + } + + describe('queued worker snapshot batching', () => { + it.each(['explicit', 'debounce'] as const)( + 'preserves getter-only edits when an explicit flush joins a dirty %s batch', + async (firstFlush) => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + const first = + firstFlush === 'explicit' + ? store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + : await vi.advanceTimersByTimeAsync(1_000) + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + store.updateSettings({ theme: 'light' }) + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + gate.finish.resolve() + await Promise.all([mutation, first, second]) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'getter-only-edit' } + }) + expect(authority.captures).toHaveLength(2) + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + } + ) + + it('preserves a queued explicit capture when a later debounce joins it', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + store.updateSettings({ theme: 'light' }) + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await Promise.all([mutation, explicit]) + expect(readState()).toMatchObject({ + settings: { theme: 'light' }, + workspaceSession: { activeTabId: 'getter-only-edit' } + }) + expect(authority.captures).toHaveLength(2) + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('keeps batches of only debounced saves selective', async () => { + const { store, authority, readState } = await fixture() + const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') + const gate = authority.pause() + vi.useFakeTimers() + try { + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + store.updateSettings({ theme: 'light' }) + await vi.advanceTimersByTimeAsync(1_000) + gate.finish.resolve() + await mutation + await store.waitForPendingWrite() + expect(readState().settings.theme).toBe('light') + expect(authority.captures).toHaveLength(2) + expect(full).not.toHaveBeenCalled() + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('captures getter-only edits in a flush requested after the preceding capture started', async () => { + const { store, authority, readState } = await fixture() + const fullCapture = vi.spyOn( + StateSerializationSecretHandlingOperations.prototype, + 'buildStateToSave' + ) + const firstGate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await firstGate.started.promise + store.getWorkspaceSession().activeTabId = 'getter-only-edit' + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + firstGate.finish.resolve() + await Promise.all([first, second]) + expect(readState().workspaceSession.activeTabId).toBe('getter-only-edit') + expect(fullCapture).toHaveBeenCalledOnce() + }) + + it('upgrades a queued debounce to capture an explicit getter-only flush', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + vi.useFakeTimers() + try { + const first = store.runDurableMutation(() => { + store.updateSettings({ theme: 'dark' }) + return { value: undefined } + }) + await gate.started.promise + await vi.advanceTimersByTimeAsync(1_000) + store.getWorkspaceSession().activeTabId = 'explicit-edit' + const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + gate.finish.resolve() + await Promise.all([first, explicit]) + expect(readState().workspaceSession.activeTabId).toBe('explicit-edit') + } finally { + gate.finish.resolve() + vi.useRealTimers() + } + }) + + it('keeps a later flush ordered after an intervening durable mutation', async () => { + const { store, authority, readState } = await fixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const before = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const mutation = store.runDurableMutation(() => { + store.updateSettings({ theme: 'light' }) + return { value: undefined } + }) + const after = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const verifyAfter = after.then(() => expect(readState().settings.theme).toBe('light')) + gate.finish.resolve() + await Promise.all([first, before, mutation, verifyAfter]) + }) + + it('shares a queued write failure with its waiters and allows a fresh retry', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + store.updateSettings({ theme: 'light' }) + const failed = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ].map((waiter) => expect(waiter).rejects.toThrow('disk refused')) + vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( + new Error('disk refused') + ) + gate.finish.resolve() + await Promise.all([first, ...failed]) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + expect(readState().settings.theme).toBe('light') + }) + + it('checks the disk revision for a clean batch and rejects every waiter on conflict', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const conflict = new ProfileStateRevisionConflictError(1, 2) + const revisionCheck = vi + .spyOn(authority, 'assertCurrentRevision') + .mockRejectedValueOnce(conflict) + const waiters = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ] + await Promise.all(waiters.map((waiter) => expect(waiter).rejects.toBe(conflict))) + expect(revisionCheck).toHaveBeenCalledOnce() + }) + + it('waits for snapshot files admitted by a later member of the queued batch', async () => { + const { store, authority, readState, runtime } = await snapshotFixture() + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + const snapshot = deferred() + runtime.pendingSnapshotFileWork = snapshot.promise + store.updateSettings({ theme: 'light' }) + const third = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + try { + gate.finish.resolve() + await first + await new Promise((resolve) => setImmediate(resolve)) + expect(authority.captures).toHaveLength(1) + } finally { + snapshot.resolve() + runtime.pendingSnapshotFileWork = null + await Promise.all([second, third]) + } + expect(readState().settings.theme).toBe('light') + }) + + it('discards a queued batch when its predecessor dependency rejects', async () => { + const { store, readState, runtime } = await snapshotFixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + runtime.pendingSnapshotFileWork = Promise.reject(new Error('snapshot preparation failed')) + const waiters = [ + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), + store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + ] + await Promise.all( + waiters.map((waiter) => expect(waiter).rejects.toThrow('snapshot preparation failed')) + ) + runtime.pendingSnapshotFileWork = null + store.updateSettings({ theme: 'light' }) + await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + expect(readState().settings.theme).toBe('light') + }) + }) +} diff --git a/src/main/persistence/loading-store/profile-state-write-batching.test.ts b/src/main/persistence/loading-store/profile-state-write-batching.test.ts deleted file mode 100644 index fb6f44a5d8a..00000000000 --- a/src/main/persistence/loading-store/profile-state-write-batching.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { deferred, fixture } from './profile-state-delayed-authority-fixture' -import { StateSerializationSecretHandlingOperations } from './state-serialization-secret-handling' -import * as composition from './store-domain-composition' -import type { StoreRuntimeState } from './store-runtime-state' -import { ProfileStateRevisionConflictError } from '../profile-state/profile-state-document-validation' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -async function snapshotFixture() { - let captured: StoreRuntimeState | undefined - const createDomains = composition.createStoreDomains - vi.spyOn(composition, 'createStoreDomains').mockImplementationOnce((runtime) => { - captured = runtime - return createDomains(runtime) - }) - const state = await fixture() - if (!captured) { - throw new Error('Store runtime was not initialized') - } - return { ...state, runtime: captured } -} - -describe('queued worker snapshot batching', () => { - it.each(['explicit', 'debounce'] as const)( - 'preserves getter-only edits when an explicit flush joins a dirty %s batch', - async (firstFlush) => { - const { store, authority, readState } = await fixture() - const gate = authority.pause() - vi.useFakeTimers() - try { - const mutation = store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await gate.started.promise - const first = - firstFlush === 'explicit' - ? store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - : await vi.advanceTimersByTimeAsync(1_000) - store.getWorkspaceSession().activeTabId = 'getter-only-edit' - store.updateSettings({ theme: 'light' }) - const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - gate.finish.resolve() - await Promise.all([mutation, first, second]) - expect(readState()).toMatchObject({ - settings: { theme: 'light' }, - workspaceSession: { activeTabId: 'getter-only-edit' } - }) - expect(authority.captures).toHaveLength(2) - } finally { - gate.finish.resolve() - vi.useRealTimers() - } - } - ) - - it('preserves a queued explicit capture when a later debounce joins it', async () => { - const { store, authority, readState } = await fixture() - const gate = authority.pause() - vi.useFakeTimers() - try { - const mutation = store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await gate.started.promise - store.getWorkspaceSession().activeTabId = 'getter-only-edit' - const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - store.updateSettings({ theme: 'light' }) - await vi.advanceTimersByTimeAsync(1_000) - gate.finish.resolve() - await Promise.all([mutation, explicit]) - expect(readState()).toMatchObject({ - settings: { theme: 'light' }, - workspaceSession: { activeTabId: 'getter-only-edit' } - }) - expect(authority.captures).toHaveLength(2) - } finally { - gate.finish.resolve() - vi.useRealTimers() - } - }) - - it('keeps batches of only debounced saves selective', async () => { - const { store, authority, readState } = await fixture() - const full = vi.spyOn(authority, 'writeCompleteSerializedDomains') - const gate = authority.pause() - vi.useFakeTimers() - try { - const mutation = store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await gate.started.promise - await vi.advanceTimersByTimeAsync(1_000) - store.updateSettings({ theme: 'light' }) - await vi.advanceTimersByTimeAsync(1_000) - gate.finish.resolve() - await mutation - await store.waitForPendingWrite() - expect(readState().settings.theme).toBe('light') - expect(authority.captures).toHaveLength(2) - expect(full).not.toHaveBeenCalled() - } finally { - gate.finish.resolve() - vi.useRealTimers() - } - }) - - it('captures getter-only edits in a flush requested after the preceding capture started', async () => { - const { store, authority, readState } = await fixture() - const fullCapture = vi.spyOn( - StateSerializationSecretHandlingOperations.prototype, - 'buildStateToSave' - ) - const firstGate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await firstGate.started.promise - store.getWorkspaceSession().activeTabId = 'getter-only-edit' - const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - firstGate.finish.resolve() - await Promise.all([first, second]) - expect(readState().workspaceSession.activeTabId).toBe('getter-only-edit') - expect(fullCapture).toHaveBeenCalledOnce() - }) - - it('upgrades a queued debounce to capture an explicit getter-only flush', async () => { - const { store, authority, readState } = await fixture() - const gate = authority.pause() - vi.useFakeTimers() - try { - const first = store.runDurableMutation(() => { - store.updateSettings({ theme: 'dark' }) - return { value: undefined } - }) - await gate.started.promise - await vi.advanceTimersByTimeAsync(1_000) - store.getWorkspaceSession().activeTabId = 'explicit-edit' - const explicit = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - gate.finish.resolve() - await Promise.all([first, explicit]) - expect(readState().workspaceSession.activeTabId).toBe('explicit-edit') - } finally { - gate.finish.resolve() - vi.useRealTimers() - } - }) - - it('keeps a later flush ordered after an intervening durable mutation', async () => { - const { store, authority, readState } = await fixture() - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - const before = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - const mutation = store.runDurableMutation(() => { - store.updateSettings({ theme: 'light' }) - return { value: undefined } - }) - const after = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - const verifyAfter = after.then(() => expect(readState().settings.theme).toBe('light')) - gate.finish.resolve() - await Promise.all([first, before, mutation, verifyAfter]) - }) - - it('shares a queued write failure with its waiters and allows a fresh retry', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - store.updateSettings({ theme: 'light' }) - const failed = [ - store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), - store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - ].map((waiter) => expect(waiter).rejects.toThrow('disk refused')) - vi.spyOn(authority, 'writeCompleteSerializedDomains').mockRejectedValueOnce( - new Error('disk refused') - ) - gate.finish.resolve() - await Promise.all([first, ...failed]) - await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - expect(readState().settings.theme).toBe('light') - }) - - it('checks the disk revision for a clean batch and rejects every waiter on conflict', async () => { - const { store, authority } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const conflict = new ProfileStateRevisionConflictError(1, 2) - const revisionCheck = vi - .spyOn(authority, 'assertCurrentRevision') - .mockRejectedValueOnce(conflict) - const waiters = [ - store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), - store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - ] - await Promise.all(waiters.map((waiter) => expect(waiter).rejects.toBe(conflict))) - expect(revisionCheck).toHaveBeenCalledOnce() - }) - - it('waits for snapshot files admitted by a later member of the queued batch', async () => { - const { store, authority, readState, runtime } = await snapshotFixture() - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const first = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - const second = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - const snapshot = deferred() - runtime.pendingSnapshotFileWork = snapshot.promise - store.updateSettings({ theme: 'light' }) - const third = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - try { - gate.finish.resolve() - await first - await new Promise((resolve) => setImmediate(resolve)) - expect(authority.captures).toHaveLength(1) - } finally { - snapshot.resolve() - runtime.pendingSnapshotFileWork = null - await Promise.all([second, third]) - } - expect(readState().settings.theme).toBe('light') - }) - - it('discards a queued batch when its predecessor dependency rejects', async () => { - const { store, readState, runtime } = await snapshotFixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - runtime.pendingSnapshotFileWork = Promise.reject(new Error('snapshot preparation failed')) - const waiters = [ - store.flushPendingOrThrowAsync({ drainToStableGeneration: false }), - store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - ] - await Promise.all( - waiters.map((waiter) => expect(waiter).rejects.toThrow('snapshot preparation failed')) - ) - runtime.pendingSnapshotFileWork = null - store.updateSettings({ theme: 'light' }) - await store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - expect(readState().settings.theme).toBe('light') - }) -}) diff --git a/src/main/persistence/loading-store/pty-binding-async-durability.spec.ts b/src/main/persistence/loading-store/pty-binding-async-durability.spec.ts new file mode 100644 index 00000000000..0759075fbef --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-async-durability.spec.ts @@ -0,0 +1,260 @@ +import { describe, expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +export function registerPtyBindingAsyncDurabilityTests(): void { + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'async-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'async-binding-pty', + incarnationId: 'async-binding-incarnation' + } + + describe('durable asynchronous PTY binding', () => { + it('acknowledges only after the binding reaches SQLite', async () => { + const { store, authority, readState } = await fixture() + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() + const gate = authority.pause() + let acknowledged = false + const pending = store.persistPtyBinding(binding).then((result) => { + acknowledged = true + return result + }) + await gate.started.promise + expect(acknowledged).toBe(false) + expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([]) + gate.finish.resolve() + expect(await pending).toBe(true) + expect( + readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: binding.ptyId + }) + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) + }) + + it('repairs activity on an otherwise matching durable reattach', async () => { + const { store, authority, readState } = await fixture() + await store.persistPtyBinding(binding) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + await store.flushPendingOrThrowAsync() + authority.captures.length = 0 + await store.persistPtyBinding(binding) + expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) + expect(authority.captures).toHaveLength(1) + await store.persistPtyBinding(binding) + expect(authority.captures).toHaveLength(1) + }) + + it('evaluates membership refusal after an older write finishes', async () => { + const { store, authority } = await fixture() + await store.persistPtyBinding(binding) + const gate = authority.pause() + store.updateSettings({ theme: 'dark' }) + const older = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) + await gate.started.promise + const resolveBinding = vi.fn(() => ({ ...binding, mayCreate: false })) + const queued = store.persistPtyBinding(resolveBinding) + expect(resolveBinding).not.toHaveBeenCalled() + const session = store.getWorkspaceSession() + session.tabsByWorktree[binding.worktreeId] = [] + delete session.terminalLayoutsByTabId[binding.tabId] + store.setWorkspaceSession(session) + gate.finish.resolve() + await older + expect(await queued).toBe(false) + expect(resolveBinding).toHaveBeenCalledTimes(1) + }) + + it('restores the binding after a known write failure', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] + const before = structuredClone(store.getWorkspaceSession()) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect(store.getWorkspaceSession()).toEqual(before) + }) + + it('preserves newer getter edits when an older binding write fails', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId = { + [binding.leafId]: 'newer-pty' + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: 'newer-pty' + }) + }) + + it.each(['tab title', 'pane title'] as const)( + 'rolls back a failed replacement while preserving a newer %s', + async (edit) => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ + ...binding, + ptyId: 'failed-replacement', + incarnationId: 'failed-incarnation', + expectedBinding: binding + }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + if (edit === 'tab title') { + tab.customTitle = 'new title' + } else { + session.terminalLayoutsByTabId[binding.tabId].titlesByLeafId = { + [binding.leafId]: 'new title' + } + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [binding.leafId]: binding.ptyId + }) + expect( + persisted.terminalPtyIncarnationsByPaneKey[`${binding.tabId}:${binding.leafId}`] + ).toBe(binding.incarnationId) + if (edit === 'tab title') { + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ id: binding.tabId, customTitle: 'new title' }) + ) + } else { + expect(persisted.terminalLayoutsByTabId[binding.tabId].titlesByLeafId).toEqual({ + [binding.leafId]: 'new title' + }) + } + } + ) + + it('rolls back a failed binding while retaining an unrelated newer navigation edit', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + store.getWorkspaceSession().activeRepoId = 'newer-repo' + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect(store.getWorkspaceSession().activeRepoId).toBe('newer-repo') + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + }) + + it('retains a newer root sibling when rolling back a failed leaf replacement', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ ...binding, ptyId: 'failed-replacement' }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + const layout = session.terminalLayoutsByTabId[binding.tabId] + layout.root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: TEST_LEAF_2 }, + second: { type: 'leaf', leafId: binding.leafId } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'new-root-pty' } + tab.ptyId = 'new-root-pty' + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(persisted.terminalLayoutsByTabId[binding.tabId].root).toEqual(layout.root) + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [binding.leafId]: binding.ptyId, + [TEST_LEAF_2]: 'new-root-pty' + }) + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ id: binding.tabId, ptyId: 'new-root-pty' }) + ) + }) + + it('removes a failed new binding while retaining a newer sibling tab', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + const tabs = store.getWorkspaceSession().tabsByWorktree[binding.worktreeId] + const boundTab = tabs.find((tab) => tab.id === binding.tabId) + if (!boundTab) { + throw new Error('binding did not create its terminal row') + } + tabs.push({ ...boundTab, id: 'newer-sibling', ptyId: 'newer-sibling-pty' }) + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) + ).toContain('newer-sibling') + expect( + store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) + ).not.toContain(binding.tabId) + expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() + }) + + it('retains a binding whose commit outcome is indeterminate', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('worker exited') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-worker-exit', 'worker exited', 'indeterminate') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({ + [binding.leafId]: binding.ptyId + }) + }) + }) +} diff --git a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts b/src/main/persistence/loading-store/pty-binding-async-durability.test.ts deleted file mode 100644 index 066cea3cce5..00000000000 --- a/src/main/persistence/loading-store/pty-binding-async-durability.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' -import { fixture } from './profile-state-delayed-authority-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -const binding = { - worktreeId: 'repo-local::/fixture/local', - tabId: 'async-binding-tab', - leafId: TEST_LEAF_1, - ptyId: 'async-binding-pty', - incarnationId: 'async-binding-incarnation' -} - -describe('durable asynchronous PTY binding', () => { - it('acknowledges only after the binding reaches SQLite', async () => { - const { store, authority, readState } = await fixture() - store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] - await store.flushPendingOrThrowAsync() - const gate = authority.pause() - let acknowledged = false - const pending = store.persistPtyBinding(binding).then((result) => { - acknowledged = true - return result - }) - await gate.started.promise - expect(acknowledged).toBe(false) - expect(readState().workspaceSession.terminalLayoutsByTabId[binding.tabId]).toBeUndefined() - expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([]) - gate.finish.resolve() - expect(await pending).toBe(true) - expect( - readState().workspaceSession.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId - ).toEqual({ - [binding.leafId]: binding.ptyId - }) - expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) - }) - - it('repairs activity on an otherwise matching durable reattach', async () => { - const { store, authority, readState } = await fixture() - await store.persistPtyBinding(binding) - store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] - await store.flushPendingOrThrowAsync() - authority.captures.length = 0 - await store.persistPtyBinding(binding) - expect(readState().workspaceSession.activeWorktreeIdsOnShutdown).toEqual([binding.worktreeId]) - expect(authority.captures).toHaveLength(1) - await store.persistPtyBinding(binding) - expect(authority.captures).toHaveLength(1) - }) - - it('evaluates membership refusal after an older write finishes', async () => { - const { store, authority } = await fixture() - await store.persistPtyBinding(binding) - const gate = authority.pause() - store.updateSettings({ theme: 'dark' }) - const older = store.flushPendingOrThrowAsync({ drainToStableGeneration: false }) - await gate.started.promise - const resolveBinding = vi.fn(() => ({ ...binding, mayCreate: false })) - const queued = store.persistPtyBinding(resolveBinding) - expect(resolveBinding).not.toHaveBeenCalled() - const session = store.getWorkspaceSession() - session.tabsByWorktree[binding.worktreeId] = [] - delete session.terminalLayoutsByTabId[binding.tabId] - store.setWorkspaceSession(session) - gate.finish.resolve() - await older - expect(await queued).toBe(false) - expect(resolveBinding).toHaveBeenCalledTimes(1) - }) - - it('restores the binding after a known write failure', async () => { - const { store, authority } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - store.getWorkspaceSession().activeWorktreeIdsOnShutdown = [] - const before = structuredClone(store.getWorkspaceSession()) - const gate = authority.pause() - const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') - await gate.started.promise - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - expect(store.getWorkspaceSession()).toEqual(before) - }) - - it('preserves newer getter edits when an older binding write fails', async () => { - const { store, authority } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const gate = authority.pause() - const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') - await gate.started.promise - store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId = { - [binding.leafId]: 'newer-pty' - } - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - expect( - store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId - ).toEqual({ - [binding.leafId]: 'newer-pty' - }) - }) - - it.each(['tab title', 'pane title'] as const)( - 'rolls back a failed replacement while preserving a newer %s', - async (edit) => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await store.persistPtyBinding(binding) - const gate = authority.pause() - const rejected = expect( - store.persistPtyBinding({ - ...binding, - ptyId: 'failed-replacement', - incarnationId: 'failed-incarnation', - expectedBinding: binding - }) - ).rejects.toThrow('disk refused') - await gate.started.promise - const session = store.getWorkspaceSession() - const tab = session.tabsByWorktree[binding.worktreeId].find( - (candidate) => candidate.id === binding.tabId - ) - if (!tab) { - throw new Error('binding did not create its terminal row') - } - if (edit === 'tab title') { - tab.customTitle = 'new title' - } else { - session.terminalLayoutsByTabId[binding.tabId].titlesByLeafId = { - [binding.leafId]: 'new title' - } - } - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - await store.flushPendingOrThrowAsync() - const persisted = readState().workspaceSession - expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ - [binding.leafId]: binding.ptyId - }) - expect(persisted.terminalPtyIncarnationsByPaneKey[`${binding.tabId}:${binding.leafId}`]).toBe( - binding.incarnationId - ) - if (edit === 'tab title') { - expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( - expect.objectContaining({ id: binding.tabId, customTitle: 'new title' }) - ) - } else { - expect(persisted.terminalLayoutsByTabId[binding.tabId].titlesByLeafId).toEqual({ - [binding.leafId]: 'new title' - }) - } - } - ) - - it('rolls back a failed binding while retaining an unrelated newer navigation edit', async () => { - const { store, authority } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const gate = authority.pause() - const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') - await gate.started.promise - store.getWorkspaceSession().activeRepoId = 'newer-repo' - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - expect(store.getWorkspaceSession().activeRepoId).toBe('newer-repo') - expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() - }) - - it('retains a newer root sibling when rolling back a failed leaf replacement', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await store.persistPtyBinding(binding) - const gate = authority.pause() - const rejected = expect( - store.persistPtyBinding({ ...binding, ptyId: 'failed-replacement' }) - ).rejects.toThrow('disk refused') - await gate.started.promise - const session = store.getWorkspaceSession() - const tab = session.tabsByWorktree[binding.worktreeId].find( - (candidate) => candidate.id === binding.tabId - ) - if (!tab) { - throw new Error('binding did not create its terminal row') - } - const layout = session.terminalLayoutsByTabId[binding.tabId] - layout.root = { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: TEST_LEAF_2 }, - second: { type: 'leaf', leafId: binding.leafId } - } - layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'new-root-pty' } - tab.ptyId = 'new-root-pty' - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - await store.flushPendingOrThrowAsync() - const persisted = readState().workspaceSession - expect(persisted.terminalLayoutsByTabId[binding.tabId].root).toEqual(layout.root) - expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ - [binding.leafId]: binding.ptyId, - [TEST_LEAF_2]: 'new-root-pty' - }) - expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( - expect.objectContaining({ id: binding.tabId, ptyId: 'new-root-pty' }) - ) - }) - - it('removes a failed new binding while retaining a newer sibling tab', async () => { - const { store, authority } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const gate = authority.pause() - const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') - await gate.started.promise - const tabs = store.getWorkspaceSession().tabsByWorktree[binding.worktreeId] - const boundTab = tabs.find((tab) => tab.id === binding.tabId) - if (!boundTab) { - throw new Error('binding did not create its terminal row') - } - tabs.push({ ...boundTab, id: 'newer-sibling', ptyId: 'newer-sibling-pty' }) - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - expect( - store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) - ).toContain('newer-sibling') - expect( - store.getWorkspaceSession().tabsByWorktree[binding.worktreeId].map((tab) => tab.id) - ).not.toContain(binding.tabId) - expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId]).toBeUndefined() - }) - - it('retains a binding whose commit outcome is indeterminate', async () => { - const { store, authority } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const gate = authority.pause() - const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('worker exited') - await gate.started.promise - gate.finish.reject( - new ProfileStateWriterError('test-worker-exit', 'worker exited', 'indeterminate') - ) - await rejected - expect( - store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId - ).toEqual({ - [binding.leafId]: binding.ptyId - }) - }) -}) diff --git a/src/main/persistence/loading-store/pty-binding-created-surface-rollback.spec.ts b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.spec.ts new file mode 100644 index 00000000000..a96ab7ac54c --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.spec.ts @@ -0,0 +1,128 @@ +import { expect, it, vi } from 'vitest' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +export function registerPtyBindingCreatedSurfaceRollbackTests(): void { + it.each(['tab title', 'pane title', 'new sibling'] as const)( + 'retains a valid unbound new surface after a failed binding and newer %s', + async (edit) => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'new-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'failed-pty', + incarnationId: 'failed-incarnation' + } + const gate = authority.pause() + const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + const session = store.getWorkspaceSession() + const tab = session.tabsByWorktree[binding.worktreeId].find( + (candidate) => candidate.id === binding.tabId + ) + if (!tab) { + throw new Error('binding did not create its terminal row') + } + const layout = session.terminalLayoutsByTabId[binding.tabId] + if (edit === 'tab title') { + tab.customTitle = 'new title' + } else if (edit === 'pane title') { + layout.titlesByLeafId = { [binding.leafId]: 'new title' } + } else { + layout.root = { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', leafId: binding.leafId }, + second: { type: 'leaf', leafId: TEST_LEAF_2 } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'sibling-pty' } + session.terminalPtyIncarnationsByPaneKey = { + ...session.terminalPtyIncarnationsByPaneKey, + [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' + } + tab.ptyId = 'sibling-pty' + } + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession + expect(JSON.stringify(persisted)).not.toContain('failed-pty') + expect(JSON.stringify(persisted)).not.toContain('failed-incarnation') + expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( + expect.objectContaining({ + id: binding.tabId, + worktreeId: binding.worktreeId, + createdAt: expect.any(Number), + ptyId: edit === 'new sibling' ? 'sibling-pty' : null, + ...(edit === 'tab title' ? { customTitle: 'new title' } : {}) + }) + ) + expect(persisted.terminalLayoutsByTabId[binding.tabId]).toMatchObject({ + root: layout.root, + ...(edit === 'pane title' ? { titlesByLeafId: { [binding.leafId]: 'new title' } } : {}) + }) + if (edit === 'new sibling') { + expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ + [TEST_LEAF_2]: 'sibling-pty' + }) + expect(persisted.terminalPtyIncarnationsByPaneKey).toEqual({ + [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' + }) + } + } + ) + + it('preserves the valid newer tree after a failed split insertion', async () => { + const { store, authority, readState } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'split-binding-tab', + leafId: TEST_LEAF_1, + ptyId: 'original-pty', + incarnationId: 'original-incarnation' + } + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect( + store.persistPtyBinding({ + ...binding, + leafId: TEST_LEAF_2, + ptyId: 'failed-pty', + incarnationId: 'failed-incarnation' + }) + ).rejects.toThrow('disk refused') + await gate.started.promise + const layout = store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId] + if (!layout.root) { + throw new Error('binding did not create its layout') + } + const laterLeaf = '33333333-3333-4333-8333-333333333333' + layout.root = { + type: 'split', + direction: 'horizontal', + first: layout.root, + second: { type: 'leaf', leafId: laterLeaf } + } + layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [laterLeaf]: 'later-sibling-pty' } + const expectedRoot = structuredClone(layout.root) + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + await store.flushPendingOrThrowAsync() + const persisted = readState().workspaceSession.terminalLayoutsByTabId[binding.tabId] + expect(persisted.root).toEqual(expectedRoot) + expect(collectLayoutLeafIdsInOrder(persisted.root)).toContain(laterLeaf) + expect(persisted.ptyIdsByLeafId).toEqual({ + [TEST_LEAF_1]: 'original-pty', + [laterLeaf]: 'later-sibling-pty' + }) + }) +} diff --git a/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts b/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts deleted file mode 100644 index 0feed681bf0..00000000000 --- a/src/main/persistence/loading-store/pty-binding-created-surface-rollback.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' -import { collectLayoutLeafIdsInOrder } from '../restoring-sessions/terminal-layout-normalization' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' -import { fixture } from './profile-state-delayed-authority-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -it.each(['tab title', 'pane title', 'new sibling'] as const)( - 'retains a valid unbound new surface after a failed binding and newer %s', - async (edit) => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const binding = { - worktreeId: 'repo-local::/fixture/local', - tabId: 'new-binding-tab', - leafId: TEST_LEAF_1, - ptyId: 'failed-pty', - incarnationId: 'failed-incarnation' - } - const gate = authority.pause() - const rejected = expect(store.persistPtyBinding(binding)).rejects.toThrow('disk refused') - await gate.started.promise - const session = store.getWorkspaceSession() - const tab = session.tabsByWorktree[binding.worktreeId].find( - (candidate) => candidate.id === binding.tabId - ) - if (!tab) { - throw new Error('binding did not create its terminal row') - } - const layout = session.terminalLayoutsByTabId[binding.tabId] - if (edit === 'tab title') { - tab.customTitle = 'new title' - } else if (edit === 'pane title') { - layout.titlesByLeafId = { [binding.leafId]: 'new title' } - } else { - layout.root = { - type: 'split', - direction: 'horizontal', - first: { type: 'leaf', leafId: binding.leafId }, - second: { type: 'leaf', leafId: TEST_LEAF_2 } - } - layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [TEST_LEAF_2]: 'sibling-pty' } - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' - } - tab.ptyId = 'sibling-pty' - } - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - await store.flushPendingOrThrowAsync() - const persisted = readState().workspaceSession - expect(JSON.stringify(persisted)).not.toContain('failed-pty') - expect(JSON.stringify(persisted)).not.toContain('failed-incarnation') - expect(persisted.tabsByWorktree[binding.worktreeId]).toContainEqual( - expect.objectContaining({ - id: binding.tabId, - worktreeId: binding.worktreeId, - createdAt: expect.any(Number), - ptyId: edit === 'new sibling' ? 'sibling-pty' : null, - ...(edit === 'tab title' ? { customTitle: 'new title' } : {}) - }) - ) - expect(persisted.terminalLayoutsByTabId[binding.tabId]).toMatchObject({ - root: layout.root, - ...(edit === 'pane title' ? { titlesByLeafId: { [binding.leafId]: 'new title' } } : {}) - }) - if (edit === 'new sibling') { - expect(persisted.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({ - [TEST_LEAF_2]: 'sibling-pty' - }) - expect(persisted.terminalPtyIncarnationsByPaneKey).toEqual({ - [`${binding.tabId}:${TEST_LEAF_2}`]: 'sibling-incarnation' - }) - } - } -) - -it('preserves the valid newer tree after a failed split insertion', async () => { - const { store, authority, readState } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - const binding = { - worktreeId: 'repo-local::/fixture/local', - tabId: 'split-binding-tab', - leafId: TEST_LEAF_1, - ptyId: 'original-pty', - incarnationId: 'original-incarnation' - } - await store.persistPtyBinding(binding) - const gate = authority.pause() - const rejected = expect( - store.persistPtyBinding({ - ...binding, - leafId: TEST_LEAF_2, - ptyId: 'failed-pty', - incarnationId: 'failed-incarnation' - }) - ).rejects.toThrow('disk refused') - await gate.started.promise - const layout = store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId] - if (!layout.root) { - throw new Error('binding did not create its layout') - } - const laterLeaf = '33333333-3333-4333-8333-333333333333' - layout.root = { - type: 'split', - direction: 'horizontal', - first: layout.root, - second: { type: 'leaf', leafId: laterLeaf } - } - layout.ptyIdsByLeafId = { ...layout.ptyIdsByLeafId, [laterLeaf]: 'later-sibling-pty' } - const expectedRoot = structuredClone(layout.root) - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - await store.flushPendingOrThrowAsync() - const persisted = readState().workspaceSession.terminalLayoutsByTabId[binding.tabId] - expect(persisted.root).toEqual(expectedRoot) - expect(collectLayoutLeafIdsInOrder(persisted.root)).toContain(laterLeaf) - expect(persisted.ptyIdsByLeafId).toEqual({ - [TEST_LEAF_1]: 'original-pty', - [laterLeaf]: 'later-sibling-pty' - }) -}) diff --git a/src/main/persistence/loading-store/pty-binding-restart-retirement.spec.ts b/src/main/persistence/loading-store/pty-binding-restart-retirement.spec.ts new file mode 100644 index 00000000000..d5f19ae114f --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-restart-retirement.spec.ts @@ -0,0 +1,76 @@ +import { expect, it, vi } from 'vitest' +import { fixture } from './profile-state-delayed-authority-fixture' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' + +export function registerPtyBindingRestartRetirementTests(): void { + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'restart-tab', + leafId: TEST_LEAF_1, + ptyId: 'old', + incarnationId: 'old-incarnation' + } + + it.each([undefined, 'ssh:restart'])( + 'durably retires only the process binding on %s', + async (hostId) => { + const { store, readState } = await fixture() + await store.persistPtyBinding(binding, hostId) + const before = structuredClone(store.getWorkspaceSession(hostId)) + expect(await store.retirePtyBinding(binding, hostId)).toBe(true) + const retired = store.getWorkspaceSession(hostId) + expect(retired.terminalLayoutsByTabId[binding.tabId]).toEqual({ + ...before.terminalLayoutsByTabId[binding.tabId], + ptyIdsByLeafId: {} + }) + expect(retired.tabsByWorktree[binding.worktreeId]).toEqual( + before.tabsByWorktree[binding.worktreeId].map((tab) => + tab.id === binding.tabId ? { ...tab, ptyId: null } : tab + ) + ) + const durable = hostId + ? readState().workspaceSessionsByHostId?.[hostId] + : readState().workspaceSession + expect(durable?.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({}) + await store.persistPtyBinding( + { ...binding, ptyId: 'new', incarnationId: 'new-incarnation' }, + hostId + ) + expect(await store.retirePtyBinding(binding, hostId)).toBe(false) + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBe('new') + } + ) + + it('clears the stopped id whatever incarnation the binding carries', async () => { + const { store } = await fixture() + await store.persistPtyBinding({ ...binding, incarnationId: 'republished-incarnation' }) + expect(await store.retirePtyBinding(binding)).toBe(true) + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId + ).toEqual({}) + }) + + it('restores the old binding after a known save failure and allows another attempt', async () => { + const { store, authority } = await fixture() + vi.spyOn(console, 'error').mockImplementation(() => {}) + await store.persistPtyBinding(binding) + const gate = authority.pause() + const rejected = expect(store.retirePtyBinding(binding)).rejects.toThrow('disk refused') + await gate.started.promise + gate.finish.reject( + new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') + ) + await rejected + expect( + store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ + binding.leafId + ] + ).toBe('old') + expect(await store.retirePtyBinding(binding)).toBe(true) + }) +} diff --git a/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts b/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts deleted file mode 100644 index 2f34af1fb0d..00000000000 --- a/src/main/persistence/loading-store/pty-binding-restart-retirement.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { fixture } from './profile-state-delayed-authority-fixture' -import { TEST_LEAF_1 } from '../../persistence-session-fixtures' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -const binding = { - worktreeId: 'repo-local::/fixture/local', - tabId: 'restart-tab', - leafId: TEST_LEAF_1, - ptyId: 'old', - incarnationId: 'old-incarnation' -} - -it.each([undefined, 'ssh:restart'])( - 'durably retires only the process binding on %s', - async (hostId) => { - const { store, readState } = await fixture() - await store.persistPtyBinding(binding, hostId) - const before = structuredClone(store.getWorkspaceSession(hostId)) - expect(await store.retirePtyBinding(binding, hostId)).toBe(true) - const retired = store.getWorkspaceSession(hostId) - expect(retired.terminalLayoutsByTabId[binding.tabId]).toEqual({ - ...before.terminalLayoutsByTabId[binding.tabId], - ptyIdsByLeafId: {} - }) - expect(retired.tabsByWorktree[binding.worktreeId]).toEqual( - before.tabsByWorktree[binding.worktreeId].map((tab) => - tab.id === binding.tabId ? { ...tab, ptyId: null } : tab - ) - ) - const durable = hostId - ? readState().workspaceSessionsByHostId?.[hostId] - : readState().workspaceSession - expect(durable?.terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual({}) - await store.persistPtyBinding( - { ...binding, ptyId: 'new', incarnationId: 'new-incarnation' }, - hostId - ) - expect(await store.retirePtyBinding(binding, hostId)).toBe(false) - expect( - store.getWorkspaceSession(hostId).terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ - binding.leafId - ] - ).toBe('new') - } -) - -it('clears the stopped id whatever incarnation the binding carries', async () => { - const { store } = await fixture() - await store.persistPtyBinding({ ...binding, incarnationId: 'republished-incarnation' }) - expect(await store.retirePtyBinding(binding)).toBe(true) - expect(store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId).toEqual( - {} - ) -}) - -it('restores the old binding after a known save failure and allows another attempt', async () => { - const { store, authority } = await fixture() - vi.spyOn(console, 'error').mockImplementation(() => {}) - await store.persistPtyBinding(binding) - const gate = authority.pause() - const rejected = expect(store.retirePtyBinding(binding)).rejects.toThrow('disk refused') - await gate.started.promise - gate.finish.reject( - new ProfileStateWriterError('test-disk-failure', 'disk refused', 'known-failure') - ) - await rejected - expect( - store.getWorkspaceSession().terminalLayoutsByTabId[binding.tabId].ptyIdsByLeafId?.[ - binding.leafId - ] - ).toBe('old') - expect(await store.retirePtyBinding(binding)).toBe(true) -}) diff --git a/src/main/persistence/loading-store/pty-retirement-async-durability.spec.ts b/src/main/persistence/loading-store/pty-retirement-async-durability.spec.ts new file mode 100644 index 00000000000..3f4445d081a --- /dev/null +++ b/src/main/persistence/loading-store/pty-retirement-async-durability.spec.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest' +import { toSshExecutionHostId } from '../../../shared/execution-host' +import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' +import { TEST_LEAF_1 } from '../../persistence-session-fixtures' +import { retireTerminalSurfaceFromPersistence } from '../../runtime/mobile-session-terminal-persistence-retirement' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' + +export function registerPtyRetirementAsyncDurabilityTests(): void { + const binding = { + worktreeId: 'repo-local::/fixture/local', + tabId: 'retirement-tab', + leafId: TEST_LEAF_1, + ptyId: 'retirement-pty', + incarnationId: 'retirement-incarnation' + } + + async function retirementFixture(connectionId: string | undefined) { + const result = await fixture() + const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined + await result.store.persistPtyBinding(binding, hostId) + return { + ...result, + retire: () => + retirePersistedStablePaneOwner( + result.store, + { ...binding, persistedIncarnationId: binding.incarnationId }, + binding.worktreeId, + connectionId + ), + removeInMemory: () => { + result.store.setWorkspaceSession( + retireTerminalSurfaceFromPersistence(result.store.getWorkspaceSession(hostId), { + ...binding, + parentTabId: binding.tabId + }), + hostId + ) + }, + persistedLayout: () => { + const state = result.readState() + const session = hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession + return session.terminalLayoutsByTabId[binding.tabId] + } + } + } + + describe.each([undefined, 'retirement-ssh'])( + 'durable PTY retirement on host %s', + (connectionId) => { + it('waits for an already removed in-memory pane to reach SQLite', async () => { + const { authority, retire, removeInMemory, persistedLayout } = + await retirementFixture(connectionId) + removeInMemory() + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + const gate = authority.pause() + let acknowledged = false + const pending = retire().then((accepted) => { + acknowledged = true + return accepted + }) + try { + await Promise.race([gate.started.promise, pending]) + expect(acknowledged).toBe(false) + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + } finally { + gate.finish.resolve() + } + await expect(pending).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + }) + + it('rejects when a pending removal cannot reach SQLite and retains it for retry', async () => { + const { authority, retire, removeInMemory, persistedLayout } = + await retirementFixture(connectionId) + vi.spyOn(console, 'error').mockImplementation(() => {}) + removeInMemory() + const gate = authority.pause() + const rejected = expect(retire()).rejects.toThrow('retirement disk refused') + try { + await Promise.race([gate.started.promise, rejected]) + gate.finish.reject( + new ProfileStateWriterError( + 'test-disk-failure', + 'retirement disk refused', + 'known-failure' + ) + ) + await rejected + } finally { + gate.finish.resolve() + } + expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) + await expect(retire()).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + }) + + it('skips disk work when the pane removal is already durable', async () => { + const { authority, retire, persistedLayout } = await retirementFixture(connectionId) + await expect(retire()).resolves.toBe(true) + expect(persistedLayout()).toBeUndefined() + authority.captures.length = 0 + const revisionCheck = vi.spyOn(authority, 'assertCurrentRevision') + const fullStateWrite = vi.spyOn(authority, 'writeSerializedState') + await expect(retire()).resolves.toBe(true) + expect(authority.captures).toEqual([]) + expect(revisionCheck).not.toHaveBeenCalled() + expect(fullStateWrite).not.toHaveBeenCalled() + }) + } + ) +} diff --git a/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts b/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts deleted file mode 100644 index 6751cc3a010..00000000000 --- a/src/main/persistence/loading-store/pty-retirement-async-durability.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { toSshExecutionHostId } from '../../../shared/execution-host' -import { retirePersistedStablePaneOwner } from '../../ipc/pty/pane/stable-owner' -import { TEST_LEAF_1 } from '../../persistence-session-fixtures' -import { retireTerminalSurfaceFromPersistence } from '../../runtime/mobile-session-terminal-persistence-retirement' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' -import { fixture } from './profile-state-delayed-authority-fixture' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -const binding = { - worktreeId: 'repo-local::/fixture/local', - tabId: 'retirement-tab', - leafId: TEST_LEAF_1, - ptyId: 'retirement-pty', - incarnationId: 'retirement-incarnation' -} - -async function retirementFixture(connectionId: string | undefined) { - const result = await fixture() - const hostId = connectionId ? toSshExecutionHostId(connectionId) : undefined - await result.store.persistPtyBinding(binding, hostId) - return { - ...result, - retire: () => - retirePersistedStablePaneOwner( - result.store, - { ...binding, persistedIncarnationId: binding.incarnationId }, - binding.worktreeId, - connectionId - ), - removeInMemory: () => { - result.store.setWorkspaceSession( - retireTerminalSurfaceFromPersistence(result.store.getWorkspaceSession(hostId), { - ...binding, - parentTabId: binding.tabId - }), - hostId - ) - }, - persistedLayout: () => { - const state = result.readState() - const session = hostId ? state.workspaceSessionsByHostId[hostId] : state.workspaceSession - return session.terminalLayoutsByTabId[binding.tabId] - } - } -} - -describe.each([undefined, 'retirement-ssh'])( - 'durable PTY retirement on host %s', - (connectionId) => { - it('waits for an already removed in-memory pane to reach SQLite', async () => { - const { authority, retire, removeInMemory, persistedLayout } = - await retirementFixture(connectionId) - removeInMemory() - expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) - const gate = authority.pause() - let acknowledged = false - const pending = retire().then((accepted) => { - acknowledged = true - return accepted - }) - try { - await Promise.race([gate.started.promise, pending]) - expect(acknowledged).toBe(false) - expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) - } finally { - gate.finish.resolve() - } - await expect(pending).resolves.toBe(true) - expect(persistedLayout()).toBeUndefined() - }) - - it('rejects when a pending removal cannot reach SQLite and retains it for retry', async () => { - const { authority, retire, removeInMemory, persistedLayout } = - await retirementFixture(connectionId) - vi.spyOn(console, 'error').mockImplementation(() => {}) - removeInMemory() - const gate = authority.pause() - const rejected = expect(retire()).rejects.toThrow('retirement disk refused') - try { - await Promise.race([gate.started.promise, rejected]) - gate.finish.reject( - new ProfileStateWriterError( - 'test-disk-failure', - 'retirement disk refused', - 'known-failure' - ) - ) - await rejected - } finally { - gate.finish.resolve() - } - expect(persistedLayout()?.ptyIdsByLeafId).toEqual({ [binding.leafId]: binding.ptyId }) - await expect(retire()).resolves.toBe(true) - expect(persistedLayout()).toBeUndefined() - }) - - it('skips disk work when the pane removal is already durable', async () => { - const { authority, retire, persistedLayout } = await retirementFixture(connectionId) - await expect(retire()).resolves.toBe(true) - expect(persistedLayout()).toBeUndefined() - authority.captures.length = 0 - const revisionCheck = vi.spyOn(authority, 'assertCurrentRevision') - const fullStateWrite = vi.spyOn(authority, 'writeSerializedState') - await expect(retire()).resolves.toBe(true) - expect(authority.captures).toEqual([]) - expect(revisionCheck).not.toHaveBeenCalled() - expect(fullStateWrite).not.toHaveBeenCalled() - }) - } -) diff --git a/src/main/persistence/loading-store/workspace-session-write-listeners.spec.ts b/src/main/persistence/loading-store/workspace-session-write-listeners.spec.ts new file mode 100644 index 00000000000..929924ddb9f --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-write-listeners.spec.ts @@ -0,0 +1,185 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' +import type { WorkspaceLayoutEvent } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { WorkspaceLayoutStream } from '../../runtime/workspace-layout-stream' +import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' +import { fixture } from './profile-state-delayed-authority-fixture' +import type { Store } from './store' + +export function registerWorkspaceSessionWriteListenersTests(): void { + const WT = 'repo-local::/fixture/local' + const SSH_HOST: ExecutionHostId = 'ssh:target-1' + const binding = { + worktreeId: WT, + tabId: 'observed-tab', + leafId: TEST_LEAF_1, + ptyId: 'observed-pty', + incarnationId: 'observed-incarnation' + } + + type Mode = 'none' | 'publisher' | 'throwing' + + /** The first partition holding the workspace's rows owns it. */ + function homeOf(store: Store, key: string): ExecutionHostId | null { + return ( + store + .getWorkspaceSessionHostIds() + .find((hostId) => Object.hasOwn(store.getWorkspaceSession(hostId).tabsByWorktree, key)) ?? + null + ) + } + + function hostSession(store: Store): WorkspaceSessionState { + return { + ...store.getWorkspaceSession(SSH_HOST), + tabsByWorktree: { + 'repo-ssh::/remote/wt': [ + { + id: 'ssh-tab', + worktreeId: 'repo-ssh::/remote/wt', + ptyId: 'ssh-pty', + title: 'Terminal', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + 'ssh-tab': { + root: { type: 'leaf', leafId: TEST_LEAF_2 }, + activeLeafId: TEST_LEAF_2, + expandedLeafId: null, + ptyIdsByLeafId: { [TEST_LEAF_2]: 'ssh-pty' } + } + } + } + } + + /** Every write shape the publisher hooks: debounced set and patch, durable bind and retire, a failed write's rollback, removal. */ + async function runScenario(mode: Mode) { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) + vi.spyOn(console, 'error').mockImplementation(() => {}) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { store, authority, readState } = await fixture() + const pushes: WorkspaceLayoutEvent[] = [] + if (mode !== 'none') { + const stream = new WorkspaceLayoutStream({ + store: () => store, + homeHostId: (key) => homeOf(store, key), + liveTitle: () => null + }) + stream.subscribe( + mode === 'publisher' + ? (event) => pushes.push(event) + : () => { + throw new Error('listener failed') + } + ) + } + const snapshots: unknown[] = [] + const step = async (run: () => unknown) => { + await run() + await store.flushPendingOrThrowAsync() + snapshots.push(structuredClone(store.getWorkspaceSession())) + } + + await step(() => store.persistPtyBinding(binding)) + await step(() => store.patchWorkspaceSession({ activeTabId: binding.tabId })) + await step(() => + store.persistPtyBinding({ ...binding, leafId: TEST_LEAF_2, ptyId: 'split-pty' }) + ) + await step(() => store.setWorkspaceSession(hostSession(store), SSH_HOST)) + await step(async () => { + authority.failNextWrite() + await store + .persistPtyBinding({ ...binding, ptyId: 'failed-pty', incarnationId: 'failed' }) + .catch((error: unknown) => error instanceof ProfileStateWriterError || error) + }) + await step(() => store.retirePtyBinding(binding)) + await step(() => store.removeWorkspaceSessionStateForWorktree(WT)) + return { + saved: withStableFixtureIds({ + captures: authority.captures, + snapshots, + persisted: readState() + }), + pushes, + warnings: warn.mock.calls.filter(([message]) => String(message).includes('workspace-layout')) + .length + } + } + + /** Each fixture store gets a fresh temp dir and leaf ids; rename them by first appearance. */ + function withStableFixtureIds(value: unknown): unknown { + const fixed = new Set([TEST_LEAF_1, TEST_LEAF_2]) + const renamed = new Map() + const json = JSON.stringify(value) + .replace(/orca-worker-coordination-[A-Za-z0-9]+/g, 'orca-worker-coordination-dir') + .replace(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/g, (id) => { + if (fixed.has(id)) { + return id + } + if (!renamed.has(id)) { + renamed.set(id, `fixture-leaf-${renamed.size}`) + } + return renamed.get(id)! + }) + return JSON.parse(json) + } + + describe('workspace session write observers are inert', () => { + it('leave saved state and write order identical, even when they throw', async () => { + const baseline = await runScenario('none') + const observed = await runScenario('publisher') + const throwing = await runScenario('throwing') + + expect(JSON.stringify(baseline.saved)).toContain('split-pty') + expect(observed.pushes.length).toBeGreaterThan(0) + expect(observed.saved).toEqual(baseline.saved) + expect(throwing.saved).toEqual(baseline.saved) + expect(throwing.warnings).toBe(1) + }) + + it('publishes each committed layout change, including durable binds and the removal', async () => { + const { pushes } = await runScenario('publisher') + + const local = pushes.filter((event) => event.key === WT) + const bindings = local.map((event) => + event.type === 'workspace' + ? event.layout.tabs + .find((tab) => tab.entityId === binding.tabId) + ?.terminal?.panes.map((pane) => [pane.leafId, pane.ptyId]) + : 'removed' + ) + expect(bindings).toEqual([ + [[TEST_LEAF_1, 'observed-pty']], + [ + [TEST_LEAF_1, 'observed-pty'], + [TEST_LEAF_2, 'split-pty'] + ], + // Memory is published before the disk write; the failed write's rollback republishes. + [ + [TEST_LEAF_1, 'failed-pty'], + [TEST_LEAF_2, 'split-pty'] + ], + [ + [TEST_LEAF_1, 'observed-pty'], + [TEST_LEAF_2, 'split-pty'] + ], + [ + [TEST_LEAF_1, undefined], + [TEST_LEAF_2, 'split-pty'] + ], + 'removed' + ]) + const ssh = pushes.filter((event) => event.key === 'repo-ssh::/remote/wt') + expect(ssh).toEqual([expect.objectContaining({ type: 'workspace' })]) + // Published from the SSH partition that owns it: its tabs run on that host. + expect(ssh[0]?.type === 'workspace' && ssh[0].layout.tabs[0]?.executionHostId).toBe(SSH_HOST) + }) + }) +} diff --git a/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts b/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts deleted file mode 100644 index f50801c19e5..00000000000 --- a/src/main/persistence/loading-store/workspace-session-write-listeners.test.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import type { ExecutionHostId } from '../../../shared/execution-host' -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' -import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' -import type { WorkspaceLayoutEvent } from '../../../shared/workspace-layout/workspace-layout-stream-frames' -import { WorkspaceLayoutStream } from '../../runtime/workspace-layout-stream' -import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' -import { fixture } from './profile-state-delayed-authority-fixture' -import type { Store } from './store' - -vi.mock('../../telemetry/client', () => ({ track: vi.fn() })) -vi.mock('../../telemetry/cohort-classifier', () => ({ - getCohortAtEmit: () => ({ nth_repo_added: 2 }) -})) -vi.mock('../../ssh/ssh-config-parser', () => ({ - loadUserSshConfig: () => ({ hosts: [] }), - sshConfigHostsToTargets: () => [] -})) - -const WT = 'repo-local::/fixture/local' -const SSH_HOST: ExecutionHostId = 'ssh:target-1' -const binding = { - worktreeId: WT, - tabId: 'observed-tab', - leafId: TEST_LEAF_1, - ptyId: 'observed-pty', - incarnationId: 'observed-incarnation' -} - -type Mode = 'none' | 'publisher' | 'throwing' - -/** The first partition holding the workspace's rows owns it. */ -function homeOf(store: Store, key: string): ExecutionHostId | null { - return ( - store - .getWorkspaceSessionHostIds() - .find((hostId) => Object.hasOwn(store.getWorkspaceSession(hostId).tabsByWorktree, key)) ?? - null - ) -} - -function hostSession(store: Store): WorkspaceSessionState { - return { - ...store.getWorkspaceSession(SSH_HOST), - tabsByWorktree: { - 'repo-ssh::/remote/wt': [ - { - id: 'ssh-tab', - worktreeId: 'repo-ssh::/remote/wt', - ptyId: 'ssh-pty', - title: 'Terminal', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'ssh-tab': { - root: { type: 'leaf', leafId: TEST_LEAF_2 }, - activeLeafId: TEST_LEAF_2, - expandedLeafId: null, - ptyIdsByLeafId: { [TEST_LEAF_2]: 'ssh-pty' } - } - } - } -} - -/** Every write shape the publisher hooks: debounced set and patch, durable bind and retire, a failed write's rollback, removal. */ -async function runScenario(mode: Mode) { - vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) - vi.spyOn(console, 'error').mockImplementation(() => {}) - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { store, authority, readState } = await fixture() - const pushes: WorkspaceLayoutEvent[] = [] - if (mode !== 'none') { - const stream = new WorkspaceLayoutStream({ - store: () => store, - homeHostId: (key) => homeOf(store, key), - liveTitle: () => null - }) - stream.subscribe( - mode === 'publisher' - ? (event) => pushes.push(event) - : () => { - throw new Error('listener failed') - } - ) - } - const snapshots: unknown[] = [] - const step = async (run: () => unknown) => { - await run() - await store.flushPendingOrThrowAsync() - snapshots.push(structuredClone(store.getWorkspaceSession())) - } - - await step(() => store.persistPtyBinding(binding)) - await step(() => store.patchWorkspaceSession({ activeTabId: binding.tabId })) - await step(() => store.persistPtyBinding({ ...binding, leafId: TEST_LEAF_2, ptyId: 'split-pty' })) - await step(() => store.setWorkspaceSession(hostSession(store), SSH_HOST)) - await step(async () => { - authority.failNextWrite() - await store - .persistPtyBinding({ ...binding, ptyId: 'failed-pty', incarnationId: 'failed' }) - .catch((error: unknown) => error instanceof ProfileStateWriterError || error) - }) - await step(() => store.retirePtyBinding(binding)) - await step(() => store.removeWorkspaceSessionStateForWorktree(WT)) - return { - saved: withStableFixtureIds({ - captures: authority.captures, - snapshots, - persisted: readState() - }), - pushes, - warnings: warn.mock.calls.filter(([message]) => String(message).includes('workspace-layout')) - .length - } -} - -/** Each fixture store gets a fresh temp dir and leaf ids; rename them by first appearance. */ -function withStableFixtureIds(value: unknown): unknown { - const fixed = new Set([TEST_LEAF_1, TEST_LEAF_2]) - const renamed = new Map() - const json = JSON.stringify(value) - .replace(/orca-worker-coordination-[A-Za-z0-9]+/g, 'orca-worker-coordination-dir') - .replace(/[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/g, (id) => { - if (fixed.has(id)) { - return id - } - if (!renamed.has(id)) { - renamed.set(id, `fixture-leaf-${renamed.size}`) - } - return renamed.get(id)! - }) - return JSON.parse(json) -} - -describe('workspace session write observers are inert', () => { - it('leave saved state and write order identical, even when they throw', async () => { - const baseline = await runScenario('none') - const observed = await runScenario('publisher') - const throwing = await runScenario('throwing') - - expect(JSON.stringify(baseline.saved)).toContain('split-pty') - expect(observed.pushes.length).toBeGreaterThan(0) - expect(observed.saved).toEqual(baseline.saved) - expect(throwing.saved).toEqual(baseline.saved) - expect(throwing.warnings).toBe(1) - }) - - it('publishes each committed layout change, including durable binds and the removal', async () => { - const { pushes } = await runScenario('publisher') - - const local = pushes.filter((event) => event.key === WT) - const bindings = local.map((event) => - event.type === 'workspace' - ? event.layout.tabs - .find((tab) => tab.entityId === binding.tabId) - ?.terminal?.panes.map((pane) => [pane.leafId, pane.ptyId]) - : 'removed' - ) - expect(bindings).toEqual([ - [[TEST_LEAF_1, 'observed-pty']], - [ - [TEST_LEAF_1, 'observed-pty'], - [TEST_LEAF_2, 'split-pty'] - ], - // Memory is published before the disk write; the failed write's rollback republishes. - [ - [TEST_LEAF_1, 'failed-pty'], - [TEST_LEAF_2, 'split-pty'] - ], - [ - [TEST_LEAF_1, 'observed-pty'], - [TEST_LEAF_2, 'split-pty'] - ], - [ - [TEST_LEAF_1, undefined], - [TEST_LEAF_2, 'split-pty'] - ], - 'removed' - ]) - const ssh = pushes.filter((event) => event.key === 'repo-ssh::/remote/wt') - expect(ssh).toEqual([expect.objectContaining({ type: 'workspace' })]) - // Published from the SSH partition that owns it: its tabs run on that host. - expect(ssh[0]?.type === 'workspace' && ssh[0].layout.tabs[0]?.executionHostId).toBe(SSH_HOST) - }) -}) diff --git a/src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts b/src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts index 8587138fdac..0b9c2fb4e2f 100644 --- a/src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts +++ b/src/renderer/src/components/contextual-tours/ContextualTourControl.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, afterEach } from 'vitest' import { toggleAutoRenameBranchFromWork } from './ContextualTourControl' import { CONTEXTUAL_TOUR_ENABLE_AUTO_WORKSPACE_NAME_EVENT } from './contextual-tour-composer-events' @@ -34,3 +34,22 @@ describe('toggleAutoRenameBranchFromWork', () => { expect(dispatchEvent).not.toHaveBeenCalled() }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts index 4b2ce9631ca..8c648531e59 100644 --- a/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts +++ b/src/renderer/src/components/contextual-tours/contextual-tour-overlay-measurement.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { getContextualTour } from '../../../../shared/contextual-tours' import { setRendererUiLanguage } from '@/i18n/i18n' import { @@ -136,3 +136,22 @@ describe('contextual tour overlay measurement', () => { ).toBe(true) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/editor/editor-dropped-file-open.test.ts b/src/renderer/src/components/editor/editor-dropped-file-open.test.ts index 452e6096982..40d86a4a0d5 100644 --- a/src/renderer/src/components/editor/editor-dropped-file-open.test.ts +++ b/src/renderer/src/components/editor/editor-dropped-file-open.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { getEditorFileDropOperationContext, shouldUploadRemoteEditorFileDrop @@ -86,3 +86,22 @@ describe('shouldUploadRemoteEditorFileDrop', () => { }) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/editor/useMarkdownDocuments.test.ts b/src/renderer/src/components/editor/useMarkdownDocuments.test.ts index a6d553c0f2b..6e69a16e9f0 100644 --- a/src/renderer/src/components/editor/useMarkdownDocuments.test.ts +++ b/src/renderer/src/components/editor/useMarkdownDocuments.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, afterEach } from 'vitest' import { saveMarkdownAndRefreshDocuments } from './useMarkdownDocuments' describe('saveMarkdownAndRefreshDocuments', () => { @@ -21,3 +21,22 @@ describe('saveMarkdownAndRefreshDocuments', () => { expect(refresh).toHaveBeenCalledTimes(1) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/pet/PetOverlay.test.ts b/src/renderer/src/components/pet/PetOverlay.test.ts index 87260e3e609..e26ad3400c5 100644 --- a/src/renderer/src/components/pet/PetOverlay.test.ts +++ b/src/renderer/src/components/pet/PetOverlay.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { clampPositionToViewport } from './PetOverlay' @@ -21,3 +21,22 @@ describe('clampPositionToViewport', () => { ).toEqual({ x: 0, y: 0 }) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/pet/pet-overlay-position.test.ts b/src/renderer/src/components/pet/pet-overlay-position.test.ts index a85c73335b6..65153d6b43a 100644 --- a/src/renderer/src/components/pet/pet-overlay-position.test.ts +++ b/src/renderer/src/components/pet/pet-overlay-position.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { clampPositionToViewport } from './PetOverlay' describe('clampPositionToViewport', () => { @@ -20,3 +20,22 @@ describe('clampPositionToViewport', () => { ).toEqual({ x: 0, y: 0 }) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts index a0754151973..597bdbbde38 100644 --- a/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts +++ b/src/renderer/src/components/right-sidebar/use-checks-panel-terminal-worktree.ts @@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { installWindowVisibilityInterval } from '@/lib/window-visibility-interval' import { useAllWorktrees, useRepoMap } from '@/store/selectors' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' import type { Worktree } from '../../../../shared/worktree/types' diff --git a/src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts b/src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts index 86ef3d3b133..a8a9884c887 100644 --- a/src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts +++ b/src/renderer/src/components/right-sidebar/use-source-control-ai.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { getSourceControlAiControllerDiscoveryHostKey } from './source-control/ai/use-ai' describe('getSourceControlAiControllerDiscoveryHostKey', () => { @@ -16,3 +16,22 @@ describe('getSourceControlAiControllerDiscoveryHostKey', () => { expect(getSourceControlAiControllerDiscoveryHostKey(owner, 'ssh-1')).toBe('runtime:env-1') }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts index 284c7113ca2..09e6f251ab4 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerKeys.test.ts @@ -46,3 +46,22 @@ describe('shouldIgnoreFileExplorerKeyTarget', () => { ).toBe(true) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/settings/KagiSessionLinkForm.test.ts b/src/renderer/src/components/settings/KagiSessionLinkForm.test.ts index c8b538329e9..4ac0a76d957 100644 --- a/src/renderer/src/components/settings/KagiSessionLinkForm.test.ts +++ b/src/renderer/src/components/settings/KagiSessionLinkForm.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { createKagiSessionLinkDraftState, resolveKagiSessionLinkDraftState @@ -28,3 +28,22 @@ describe('KagiSessionLinkForm draft state', () => { }) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts b/src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts index 86365a57f16..7fb393af63b 100644 --- a/src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts +++ b/src/renderer/src/components/settings/PluginKeybindingConsentPreview.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { shadowedKeybindingTitles } from './PluginKeybindingConsentPreview' describe('plugin keybinding consent preview', () => { @@ -9,3 +9,22 @@ describe('plugin keybinding consent preview', () => { ).toContain('Open Tasks') }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/settings/QuickCommandsPane.test.ts b/src/renderer/src/components/settings/QuickCommandsPane.test.ts index 877134ffcea..17a8aaf3478 100644 --- a/src/renderer/src/components/settings/QuickCommandsPane.test.ts +++ b/src/renderer/src/components/settings/QuickCommandsPane.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { getAvailableQuickCommandHostId, isQuickCommandEditorHostCurrent, @@ -56,3 +56,22 @@ describe('QuickCommandsPane host state', () => { ) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/settings/settings-setup-guide-progress.test.ts b/src/renderer/src/components/settings/settings-setup-guide-progress.test.ts index 18ebe0e0723..e3e23e48da0 100644 --- a/src/renderer/src/components/settings/settings-setup-guide-progress.test.ts +++ b/src/renderer/src/components/settings/settings-setup-guide-progress.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { FEATURE_WALL_SETUP_STEPS, type FeatureWallSetupStepId @@ -81,3 +81,22 @@ describe('settings setup guide progress', () => { expect(progress.doneCount).toBe(1) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts index f26f6f96f5d..d41777bac78 100644 --- a/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeParentPickerPopover.test.ts @@ -235,3 +235,22 @@ describe('parent picker keyboard input', () => { expect(stopPropagation).not.toHaveBeenCalled() }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts b/src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts index 3fddb98dbd1..a7ac3e6c060 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-linked-startup-plan.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { buildFolderWorkspaceLinkedStartupPlan } from './folder-workspace-composer-submit' +import { buildFolderWorkspaceLinkedStartupPlan } from './folder-workspace-agent-startup' describe('buildFolderWorkspaceLinkedStartupPlan', () => { it('uses cmd quoting for configured arguments on local Windows', () => { diff --git a/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts b/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts index d0a749c0d14..afdb52d1831 100644 --- a/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts +++ b/src/renderer/src/components/sidebar/hovered-workspace-delete.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, afterEach } from 'vitest' import type { AppState } from '@/store/types' import type { Worktree } from '../../../../shared/worktree/types' import { @@ -221,3 +221,22 @@ describe('hovered workspace delete', () => { finishDelete(false) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts index c9b5092f76e..b481a35cde7 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-statuses.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { shallow } from 'zustand/shallow' import { selectWorktreeActivityStatuses } from './use-worktree-activity-statuses' @@ -44,3 +44,22 @@ describe('selectWorktreeActivityStatuses', () => { ).toBe(true) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-model.git-status.test.ts b/src/renderer/src/components/tab-bar/tab-bar-item-model.git-status.test.ts index e32dc566a3d..9679e842d83 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-model.git-status.test.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-item-model.git-status.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import type { GitFileStatus } from '../../../../shared/git-status-types' import { resolveEditorTabGitStatus } from './tab-bar-item-model' @@ -20,3 +20,22 @@ describe('resolveEditorTabGitStatus', () => { } ) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/task-page-gitlab-task-filters.test.ts b/src/renderer/src/components/task-page-gitlab-task-filters.test.ts index 55dec516171..b7a531ad065 100644 --- a/src/renderer/src/components/task-page-gitlab-task-filters.test.ts +++ b/src/renderer/src/components/task-page-gitlab-task-filters.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { isGitLabIssueFilter, isGitLabMRFilter } from './task-page-source-context' describe('isGitLabMRFilter', () => { @@ -26,3 +26,22 @@ describe('isGitLabIssueFilter', () => { expect(isGitLabIssueFilter('all')).toBe(false) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/task-page-repo-source-context.test.ts b/src/renderer/src/components/task-page-repo-source-context.test.ts index 4a90e04f352..fbba8162fbf 100644 --- a/src/renderer/src/components/task-page-repo-source-context.test.ts +++ b/src/renderer/src/components/task-page-repo-source-context.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { buildGitLabProviderIdentity, getTaskPageRepoCacheInput } from './task-page-source-context' import type { Repo } from '../../../shared/repo-types' @@ -47,3 +47,22 @@ describe('getTaskPageRepoCacheInput', () => { expect(input.executionHostId).toBe('local') }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/task-page-task-source-host-availability.test.ts b/src/renderer/src/components/task-page-task-source-host-availability.test.ts index c3dde4bb742..28c7bbb2372 100644 --- a/src/renderer/src/components/task-page-task-source-host-availability.test.ts +++ b/src/renderer/src/components/task-page-task-source-host-availability.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' import type { ExecutionHostRegistryEntry } from '../../../shared/execution-host-registry' import { @@ -112,3 +112,22 @@ describe('getGitHubProjectSourceSummary', () => { expect(summary.label).not.toBe('GitHub · Build server · Current account') }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index c1772cd43ff..2e3ccb0efee 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -14,7 +14,7 @@ import { hasRegisteredRuntimeTerminalTab } from '../runtime/mounted-terminal-tab import { anyMountedWorktreeHasLayout as computeAnyMountedWorktreeHasLayout } from './terminal/split-group-mount' import { isParkRestorableTerminalPty } from './terminal-pane/terminal-hidden-view-parking' import { canWatcherCoverParkedTerminalTab } from './terminal-pane/terminal-parked-tab-watchers' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { terminalProviderHasAuthoritativeSnapshot } from './terminal/terminal-provider-snapshot-capability' import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts index 8410352da7a..d82b6eae74e 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -28,7 +28,7 @@ import { } from './terminal-side-effect-facts-handler' import { dispatchTerminalNotification } from './use-notification-dispatch' import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' // Why: keep the live path's BEL-vs-completion race window so notification behavior is identical whether a tab is parked or mounted. const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS diff --git a/src/renderer/src/components/terminal-pane/remote-execution-host-pty.ts b/src/renderer/src/components/terminal-pane/remote-execution-host-pty.ts index 38fbee322e9..16c8147a4f9 100644 --- a/src/renderer/src/components/terminal-pane/remote-execution-host-pty.ts +++ b/src/renderer/src/components/terminal-pane/remote-execution-host-pty.ts @@ -1,4 +1,4 @@ -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' diff --git a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts index ba05c496e3e..6be31a098fc 100644 --- a/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts +++ b/src/renderer/src/components/terminal-pane/resolve-split-cwd.ts @@ -3,7 +3,7 @@ // `/proc`-or-lsof-backed IPC fallback for shells that never emit OSC 7 (agent // TUIs, minimal sh). Both layers can legitimately come back empty, so the // helper always finishes by returning the caller's worktree-root fallback. -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' export type PaneCwdEntry = { cwd: string diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts index 582ea1f273d..8ad4ee5d19f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -1,4 +1,4 @@ -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { isRemoteExecutionHostPtyId } from './remote-execution-host-pty' import { getRemoteRuntimePtyEnvironmentId } from '@/runtime/runtime-terminal-stream' import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts index 3ad0730d028..6bdf2d43607 100644 --- a/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-worktree-retention.ts @@ -1,4 +1,4 @@ -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { isRemoteExecutionHostPtyId } from './remote-execution-host-pty' import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' import { terminalProviderHasAuthoritativeSnapshot } from '../terminal/terminal-provider-snapshot-capability' diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-search-dispatch.test.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-search-dispatch.test.ts index 65b58ec1c2f..5da7f10f908 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-search-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-search-dispatch.test.ts @@ -1,6 +1,6 @@ // @vitest-environment happy-dom -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it, vi, afterEach } from 'vitest' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchTerminalShortcutAction } from './terminal-keyboard-action-dispatch' import { resolveTerminalShortcutAction } from './terminal-shortcut-policy' @@ -72,3 +72,22 @@ describe('terminal find shortcut dispatch', () => { expect(context.setSearchOpen).not.toHaveBeenCalled() }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts index 859f6c97168..16cc6fb91a1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-live-layout-reconciliation.ts @@ -2,7 +2,7 @@ import type { TerminalPaneLayoutNode, TerminalPaneSplitDirection } from '../../../../shared/terminal-tab-types' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { collectLeafIds } from './terminal-pane-layout-tree' /** diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts index 7541630a278..fda1cdf3798 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-pty-watcher.ts @@ -1,6 +1,6 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import { isProvenProcessExit } from '../../../../shared/terminal-exit-cause' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { useAppStore } from '@/store' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' diff --git a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts index d3d9b44d544..1504ce16410 100644 --- a/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts +++ b/src/renderer/src/components/terminal-pane/use-manual-terminal-worktree-parking.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, afterEach, vi } from 'vitest' import { combineTerminalWorktreeParkIds } from './use-manual-terminal-worktree-parking' describe('combineTerminalWorktreeParkIds', () => { @@ -14,3 +14,22 @@ describe('combineTerminalWorktreeParkIds', () => { expect(combineTerminalWorktreeParkIds(automatic, new Set())).toBe(automatic) }) }) + +const { rejectAppStoreAccess } = vi.hoisted(() => ({ + rejectAppStoreAccess: vi.fn(() => { + throw new Error('Pure helper tests must not access the app store') + }) +})) + +vi.mock('@/store', () => ({ + useAppStore: Object.assign(rejectAppStoreAccess, { + getState: rejectAppStoreAccess, + getInitialState: rejectAppStoreAccess, + setState: rejectAppStoreAccess, + subscribe: rejectAppStoreAccess + }) +})) + +afterEach(() => { + expect(rejectAppStoreAccess).not.toHaveBeenCalled() +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts index f4725168525..cf9182128fa 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-layout-persistence.ts @@ -4,7 +4,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { clearWebRuntimeTerminalBuffer } from '@/runtime/web-runtime-terminal-buffer-actions' import { diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index 995cb975896..bb6e6fd8ac2 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -2,7 +2,7 @@ import { subscribeToPtyData } from '@/components/terminal-pane/pty-data-sidecar- import { subscribeToPtyExit } from '@/components/terminal-pane/pty-dispatcher' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getRemoteRuntimeTerminalMultiplexer } from '@/runtime/remote-runtime-terminal-multiplexer' -import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { isRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { getRemoteRuntimePtyOwner } from '@/runtime/runtime-terminal-stream' import { useAppStore } from '@/store' import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc'