From f97555ee19a51e463ef3017a4f6c1ce88140174f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:17:26 -0700 Subject: [PATCH 01/11] feat(native-chat): add a chat-owned command receipt store Adds the agent_session_command_receipts table and an insert-if-absent receipt write that runs inside a command's own journal transaction. A receipt records the command's identity, canonical hash, status and a pointer to the row holding its result; it dies with its chat record. Nothing uses it yet: the existing operation ledger stays authoritative. --- .../command-receipt-schema.test.ts | 152 ++++++++++++++ .../command-receipt-schema.ts | 107 ++++++++++ .../command-receipt-table.test.ts | 187 ++++++++++++++++++ .../command-receipt-table.ts | 130 ++++++++++++ .../command-receipt-test-support.ts | 41 ++++ .../command-receipt-transaction.test.ts | 181 +++++++++++++++++ .../command-receipt-transaction.ts | 30 +++ .../agent-session-journal/journal-database.ts | 2 + 8 files changed, 830 insertions(+) create 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-schema.ts create 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-table.ts create mode 100644 src/main/native-chat/agent-session-journal/command-receipt-test-support.ts create mode 100644 src/main/native-chat/agent-session-journal/command-receipt-transaction.test.ts create mode 100644 src/main/native-chat/agent-session-journal/command-receipt-transaction.ts 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 new file mode 100644 index 000000000000..73b0aaa6ab76 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.test.ts @@ -0,0 +1,152 @@ +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 { + 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 + +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 table 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("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") + .get('agent_session_command_receipts_session') + ).toBeDefined() + 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, 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, receipt.scope, receipt.operationId)).toEqual({ + verdict: 'readable', + receipt + }) + const write = () => insertCommandReceiptIfAbsent(db, receipt) + expect(write).toThrow(AgentSessionJournalError) + expect(write).toThrow(expect.objectContaining({ code: 'journal_read_only' })) + expect(() => buildCommandReceiptTransaction(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, receipt.scope, receipt.operationId)).toEqual({ + verdict: 'unreadable', + scope: receipt.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.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts new file mode 100644 index 000000000000..6f5abb8cbd14 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts @@ -0,0 +1,107 @@ +import { isDeepStrictEqual } from 'node:util' +import { z } from 'zod' +import type Database from '../../sqlite/sync-database' +import { readAgentSessionRefusalReference } from '../../../shared/agent-session-wire-refusals' + +export const commandReceiptScopeSchema = z.discriminatedUnion('kind', [ + z.strictObject({ kind: z.literal('global') }), + z.strictObject({ kind: z.literal('caller'), callerKey: z.string().min(1) }) +]) + +export type CommandReceiptScope = z.infer + +/** Sends use global identity; other methods keep the authenticated caller's namespace. */ +export function commandReceiptScope( + callerKey: string, + operationIdScope?: 'global' +): CommandReceiptScope { + return operationIdScope === 'global' ? { kind: 'global' } : { kind: 'caller', callerKey } +} + +export function commandReceiptScopeKey(scope: CommandReceiptScope): string { + return scope.kind === 'global' ? 'global' : `caller:${scope.callerKey}` +} + +/** A missing referenced row is spent proof: replay answers unknown, never repeats the effect. */ +export const commandReceiptResultSchema = z.discriminatedUnion('kind', [ + z.strictObject({ + kind: z.literal('journal-row'), + epoch: z.string().min(1), + sequence: z.int().positive() + }), + z.strictObject({ + kind: z.literal('no-op'), + outcome: z.discriminatedUnion('kind', [ + z.strictObject({ + kind: z.literal('cancel'), + cancelled: z.literal(false), + turnId: z.string().optional() + }), + z.strictObject({ kind: z.literal('queue-resume'), resumed: z.literal(false) }) + ]) + }) +]) + +export type CommandReceiptResult = z.infer + +const rejectionSchema = z + .strictObject({ reference: z.unknown(), message: z.string().optional() }) + .transform((value, ctx) => { + const reference = readAgentSessionRefusalReference(value.reference) + if (!reference || !isDeepStrictEqual(reference, value.reference)) { + ctx.addIssue({ code: 'custom', message: 'unreadable command refusal' }) + return z.NEVER + } + return { reference, ...(value.message !== undefined ? { message: value.message } : {}) } + }) + +const receiptIdentity = { + scope: commandReceiptScopeSchema, + operationId: z.string().min(1), + sessionId: z.string().min(1), + callerKey: z.string().min(1), + method: z.string().min(1), + fingerprint: z.string().min(1), + acceptedAt: z.int().nonnegative() +} + +export const commandReceiptSchema = z + .discriminatedUnion('status', [ + z.strictObject({ + ...receiptIdentity, + status: z.literal('accepted'), + result: commandReceiptResultSchema + }), + z.strictObject({ + ...receiptIdentity, + status: z.literal('rejected'), + rejection: rejectionSchema + }) + ]) + .refine( + (receipt) => receipt.scope.kind === 'global' || receipt.scope.callerKey === receipt.callerKey, + { message: 'command caller does not match its scope' } + ) + +export type CommandReceipt = z.infer + +/** Additive and inert until cutover; a version bump here would prevent older builds writing. */ +export function ensureCommandReceiptsTable(db: Database.Database): void { + db.exec(` +CREATE TABLE IF NOT EXISTS agent_session_command_receipts ( + scope TEXT NOT NULL, + operation_id TEXT NOT NULL, + session_id TEXT NOT NULL REFERENCES agent_session_records(session_id) ON DELETE CASCADE, + caller_key TEXT NOT NULL, + method TEXT NOT NULL, + fingerprint TEXT NOT NULL, + status TEXT NOT NULL, + result_json TEXT, + rejection_json TEXT, + accepted_at INTEGER NOT NULL, + PRIMARY KEY (scope, operation_id) +); +CREATE INDEX IF NOT EXISTS agent_session_command_receipts_session + ON agent_session_command_receipts (session_id); +`) +} 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 new file mode 100644 index 000000000000..45ac56472d7f --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-table.test.ts @@ -0,0 +1,187 @@ +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 { JournalHostDatabase } from './journal-host-database' +import { + closeTestJournalHostDatabases, + openTestJournalHostDatabase +} from './journal-host-database-test-support' +import { + commandReceiptScope, + commandReceiptScopeKey, + type CommandReceipt +} 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 + +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) { + return database.transaction((db) => insertCommandReceiptIfAbsent(db, receipt)) +} + +function read(receipt: CommandReceipt) { + return readCommandReceipt(database.db, receipt.scope, receipt.operationId) +} + +describe('command receipt identity', () => { + it('inserts once and returns the established receipt on a duplicate', () => { + 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('isolates global and caller namespaces without scope string collisions', () => { + const receipts = [ + commandReceiptFixture(), + commandReceiptFixture({ scope: commandReceiptScope('caller-1') }), + commandReceiptFixture({ scope: commandReceiptScope('caller-2'), callerKey: 'caller-2' }), + commandReceiptFixture({ scope: commandReceiptScope('global'), callerKey: 'global' }) + ] + for (const receipt of receipts) { + expect(insert(receipt)).toEqual({ inserted: true }) + expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) + } + }) + + it('refuses a caller that does not match its namespace', () => { + const receipt = commandReceiptFixture({ scope: commandReceiptScope('caller-2') }) + expect(() => insert(receipt)).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 an effect transaction instead of committing a preliminary reservation', () => { + const receipt = commandReceiptFixture() + expect(() => insertCommandReceiptIfAbsent(database.db, receipt)).toThrow(/effect transaction/) + expect(read(receipt)).toEqual({ verdict: 'absent' }) + }) +}) + +describe('command receipt outcomes and lifetime', () => { + 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', 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: receipt.scope, + 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 scope = ? AND operation_id = ?`) + .run( + JSON.stringify({ + reference: { code: 'agent_session_operation_invalid', details: { reason: 'madeUp' } } + }), + commandReceiptScopeKey(receipt.scope), + receipt.operationId + ) + expect(read(receipt)).toMatchObject({ verdict: 'unreadable' }) + expect(insert(receipt)).toMatchObject({ inserted: false, reason: 'unreadable' }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/command-receipt-table.ts b/src/main/native-chat/agent-session-journal/command-receipt-table.ts new file mode 100644 index 000000000000..03770fb912d1 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-table.ts @@ -0,0 +1,130 @@ +import type Database from '../../sqlite/sync-database' +import { journalPragmaNumber } from './journal-database' +import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' +import { assertJournalWritable } from './journal-write-guards' +import { + commandReceiptSchema, + commandReceiptScopeKey, + type CommandReceipt, + type CommandReceiptScope +} from './command-receipt-schema' + +export type CommandReceiptRead = + | { verdict: 'absent' } + | { verdict: 'readable'; receipt: CommandReceipt } + | { verdict: 'unreadable'; scope: CommandReceiptScope; operationId: string } + +export type ExistingCommandReceipt = Exclude + +export type CommandReceiptInsert = + | { inserted: true } + | { + inserted: false + reason: 'duplicate' | 'conflict' + existing: Extract + } + | { + inserted: false + reason: 'unreadable' + existing: Extract + } + +const SELECT_RECEIPT = `SELECT session_id, caller_key, method, fingerprint, status, + result_json, rejection_json, accepted_at FROM agent_session_command_receipts + WHERE scope = ? AND operation_id = ?` + +function parseReceiptJson(value: unknown): unknown { + return typeof value === 'string' ? JSON.parse(value) : undefined +} + +export function readCommandReceipt( + db: Database.Database, + scope: CommandReceiptScope, + operationId: string +): CommandReceiptRead { + const unreadable: CommandReceiptRead = { verdict: 'unreadable', scope, operationId } + // A newer read-only database may not expose this table; that cannot authorize a retry. + if ( + !db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('agent_session_command_receipts') + ) { + return unreadable + } + const row = db.prepare(SELECT_RECEIPT).get(commandReceiptScopeKey(scope), operationId) + if (!row) { + return { verdict: 'absent' } + } + try { + if ( + (row.status === 'accepted' && row.rejection_json !== null) || + (row.status === 'rejected' && row.result_json !== null) + ) { + return unreadable + } + const parsed = commandReceiptSchema.safeParse({ + scope, + operationId, + sessionId: row.session_id, + callerKey: row.caller_key, + method: row.method, + fingerprint: row.fingerprint, + status: row.status, + acceptedAt: row.accepted_at, + ...(row.status === 'accepted' + ? { result: parseReceiptJson(row.result_json) } + : { rejection: parseReceiptJson(row.rejection_json) }) + }) + return parsed.success ? { verdict: 'readable', receipt: parsed.data } : unreadable + } catch { + return unreadable + } +} + +/** The caller owns the effect's transaction; this function never reserves or commits on its own. */ +export function insertCommandReceiptIfAbsent( + db: Database.Database, + receipt: CommandReceipt +): CommandReceiptInsert { + assertJournalWritable( + journalPragmaNumber(db, 'user_version') > JOURNAL_DB_SCHEMA_VERSION, + receipt.sessionId + ) + if (!db.isTransaction) { + throw new Error('a command receipt must be written inside its effect transaction') + } + const written = commandReceiptSchema.parse(receipt) + const inserted = db + .prepare(`INSERT INTO agent_session_command_receipts + (scope, operation_id, session_id, caller_key, method, fingerprint, status, + result_json, rejection_json, accepted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(scope, operation_id) DO NOTHING`) + .run( + commandReceiptScopeKey(written.scope), + written.operationId, + written.sessionId, + written.callerKey, + written.method, + written.fingerprint, + written.status, + written.status === 'accepted' ? JSON.stringify(written.result) : null, + written.status === 'rejected' ? JSON.stringify(written.rejection) : null, + written.acceptedAt + ) + if (Number(inserted.changes) === 1) { + return { inserted: true } + } + const existing = readCommandReceipt(db, written.scope, written.operationId) + if (existing.verdict === 'absent') { + throw new Error('an existing command receipt disappeared inside its transaction') + } + if (existing.verdict === 'unreadable') { + return { inserted: false, reason: 'unreadable', existing } + } + return { + inserted: false, + existing, + reason: existing.receipt.fingerprint === written.fingerprint ? 'duplicate' : 'conflict' + } +} diff --git a/src/main/native-chat/agent-session-journal/command-receipt-test-support.ts b/src/main/native-chat/agent-session-journal/command-receipt-test-support.ts new file mode 100644 index 000000000000..980d782e570d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-test-support.ts @@ -0,0 +1,41 @@ +import type Database from '../../sqlite/sync-database' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { writeAgentSessionStoreRows } from '../../runtime/agent-session-record-rows' +import { commandReceiptScope, type CommandReceipt } from './command-receipt-schema' + +type AcceptedReceipt = Extract + +export function commandReceiptFixture(overrides: Partial = {}): AcceptedReceipt { + return { + scope: commandReceiptScope('caller-1', 'global'), + operationId: 'operation-1', + sessionId: 'session-1', + callerKey: 'caller-1', + method: 'agentSession.send', + fingerprint: computeAgentSessionPayloadFingerprint({ + method: 'agentSession.send', + sessionId: 'session-1', + fields: { body: { text: 'hello' } } + }), + acceptedAt: 1000, + status: 'accepted', + result: { kind: 'journal-row', epoch: 'epoch-1', sequence: 1 }, + ...overrides + } +} + +export function writeCommandReceiptTestRecord( + db: Database.Database, + sessionId = 'session-1', + recordJson: string | null = '{}' +): void { + writeAgentSessionStoreRows(db, { + records: { + upsert: recordJson === null ? [] : [[sessionId, recordJson]], + remove: recordJson === null ? [sessionId] : [] + }, + operations: { upsert: [], remove: [] }, + retiredClaimKeys: null, + sessionTabs: null + }) +} 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 new file mode 100644 index 000000000000..425f2dbf6060 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-transaction.test.ts @@ -0,0 +1,181 @@ +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 { + commandReceiptFixture, + writeCommandReceiptTestRecord +} from './command-receipt-test-support' +import { + buildCommandReceiptTransaction, + CommandReceiptExistsError +} from './command-receipt-transaction' + +let root: string +let database: JournalHostDatabase +const receipt = commandReceiptFixture() +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, receipt.scope, receipt.operationId) +} + +describe('command receipt transaction hook', () => { + it('rolls back the effect and receipt together if the enclosing transaction throws', () => { + const hook = buildCommandReceiptTransaction(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[] = [] + let result = receipt.result + const writer = new JournalRowWriter({ + sessionId: identity.sessionId, + now: () => 1000, + serialize: (run) => queue.serialize(run), + database: () => database, + readOnly: () => database.readOnly, + highestFence: () => 0, + nextSequence: () => 1, + commit: (row) => committed.push(row) + }) + await writer.enqueue( + epochRow, + (_db, row) => { + result = { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } + }, + buildCommandReceiptTransaction(() => ({ ...receipt, result })) + ) + expect(committed).toHaveLength(1) + expect(storedRows()).toEqual([{ seq: 1 }]) + expect(readReceipt()).toEqual({ verdict: 'readable', receipt }) + }) + + it.each(['duplicate', 'conflict', 'unreadable'] as const)( + 'throws a typed %s verdict and rolls back the second effect', + (reason) => { + database.transaction((db) => insertCommandReceiptIfAbsent(db, receipt)) + if (reason === 'unreadable') { + database.db.prepare("UPDATE agent_session_command_receipts SET result_json = '{'").run() + } + const hook = buildCommandReceiptTransaction({ + ...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, 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, 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, receipt))).toMatchObject({ + inserted: false, + reason: 'duplicate' + }) + }) +}) diff --git a/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts b/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts new file mode 100644 index 000000000000..9f747d49e63d --- /dev/null +++ b/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts @@ -0,0 +1,30 @@ +import type { CommandReceipt } from './command-receipt-schema' +import { insertCommandReceiptIfAbsent, type CommandReceiptInsert } from './command-receipt-table' +import type { JournalOperationReceipt } from './journal-row-writer' + +type CommandReceiptNotInserted = Extract + +/** The enclosing effect rolls back; its caller replays, refuses conflict, or answers unknown. */ +export class CommandReceiptExistsError extends Error { + constructor(readonly result: CommandReceiptNotInserted) { + super(`command receipt already exists: ${result.reason}`) + this.name = 'CommandReceiptExistsError' + } +} + +export function buildCommandReceiptTransaction( + receipt: CommandReceipt | (() => CommandReceipt) +): JournalOperationReceipt { + return { + write: (db) => { + const result = insertCommandReceiptIfAbsent( + db, + typeof receipt === 'function' ? receipt() : receipt + ) + if (!result.inserted) { + throw new CommandReceiptExistsError(result) + } + }, + committed: () => undefined + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-database.ts b/src/main/native-chat/agent-session-journal/journal-database.ts index ba961541a7e9..0dee9537b3f1 100644 --- a/src/main/native-chat/agent-session-journal/journal-database.ts +++ b/src/main/native-chat/agent-session-journal/journal-database.ts @@ -14,6 +14,7 @@ import { } from './journal-database-schema' import { JournalUnreleasedSchemaError } from './journal-open-failure' import { ensureQueuedMessagesTable } from './queued-message-schema' +import { ensureCommandReceiptsTable } from './command-receipt-schema' import { ensureAgentSessionAttachmentClaimTables } from '../agent-session-attachments/agent-session-attachment-claims' export const JOURNAL_BUSY_TIMEOUT_MS = 5000 @@ -81,6 +82,7 @@ export function openJournalDatabase(dbPath: string): OpenJournalDatabase { // already at the current version, and this table must exist at EVERY // writable open with no `user_version` bump (see `ensureQueuedMessagesTable`). ensureQueuedMessagesTable(probe) + ensureCommandReceiptsTable(probe) ensureAgentSessionAttachmentClaimTables(probe) hardenSqliteDatabaseFiles(dbPath) transferred = true From 3eb7a01fb894c309a3fb9e8d641473048dbf6c32 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 12:55:13 -0700 Subject: [PATCH 02/11] fix(native-chat): key command receipts like today's ledger ids Rows are keyed by caller and operation id; a send looks across callers and other methods look up only their caller's row, matching the existing ledger. Bad rows and newer table shapes read as unreadable instead of throwing. --- .../command-receipt-schema.test.ts | 40 +++- .../command-receipt-schema.ts | 43 ++-- .../command-receipt-table.test.ts | 211 +++++++++++++++--- .../command-receipt-table.ts | 174 +++++++++------ .../command-receipt-test-support.ts | 3 +- .../command-receipt-transaction.test.ts | 31 ++- .../command-receipt-transaction.ts | 4 +- 7 files changed, 362 insertions(+), 144 deletions(-) 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 index 73b0aaa6ab76..2fab511614be 100644 --- 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 @@ -15,6 +15,7 @@ import { openTestJournalHostDatabase } from './journal-host-database-test-support' import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table' +import { commandReceiptScope } from './command-receipt-schema' import { commandReceiptFixture, writeCommandReceiptTestRecord @@ -24,6 +25,7 @@ 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-')) @@ -44,15 +46,29 @@ function hasReceiptTable(db: Database.Database): boolean { } describe('command receipt schema at writable open', () => { - it('creates the table and session index on a fresh host database', () => { + it('creates the caller/id primary key and lookup indexes 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("SELECT name FROM sqlite_master WHERE type = 'index' AND name = ?") - .get('agent_session_command_receipts_session') - ).toBeDefined() + expect(database.db.prepare('PRAGMA table_info(agent_session_command_receipts)').all()).toEqual([ + expect.objectContaining({ name: 'operation_id', pk: 2 }), + expect.objectContaining({ name: 'session_id', pk: 0 }), + expect.objectContaining({ name: 'caller_key', pk: 1 }), + 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 }) + ]) + for (const [index, column] of [ + ['operation', 'operation_id'], + ['session', 'session_id'] + ] as const) { + expect( + database.db.prepare(`PRAGMA index_info(agent_session_command_receipts_${index})`).all() + ).toEqual([expect.objectContaining({ name: column })]) + } expect( database.db.prepare('PRAGMA foreign_key_list(agent_session_command_receipts)').all() ).toEqual([ @@ -100,7 +116,7 @@ describe('command receipts on a newer database', () => { const receipt = commandReceiptFixture() runJournalTransaction(seeded, (db) => { writeCommandReceiptTestRecord(db) - insertCommandReceiptIfAbsent(db, receipt) + insertCommandReceiptIfAbsent(db, scope, receipt) }) seeded.pragma(`user_version = ${JOURNAL_DB_SCHEMA_VERSION + 1}`) seeded.close() @@ -109,14 +125,14 @@ describe('command receipts on a newer database', () => { const { db, readOnly } = openJournalDatabase(dbPath) try { expect(readOnly).toBe(true) - expect(readCommandReceipt(db, receipt.scope, receipt.operationId)).toEqual({ + expect(readCommandReceipt(db, scope, receipt.operationId)).toEqual({ verdict: 'readable', receipt }) - const write = () => insertCommandReceiptIfAbsent(db, receipt) + const write = () => insertCommandReceiptIfAbsent(db, scope, receipt) expect(write).toThrow(AgentSessionJournalError) expect(write).toThrow(expect.objectContaining({ code: 'journal_read_only' })) - expect(() => buildCommandReceiptTransaction(receipt).write(db)).toThrow( + expect(() => buildCommandReceiptTransaction(scope, receipt).write(db)).toThrow( expect.objectContaining({ code: 'journal_read_only' }) ) } finally { @@ -139,9 +155,9 @@ describe('command receipts on a newer database', () => { try { const receipt = commandReceiptFixture() expect(hasReceiptTable(db)).toBe(false) - expect(readCommandReceipt(db, receipt.scope, receipt.operationId)).toEqual({ + expect(readCommandReceipt(db, scope, receipt.operationId)).toEqual({ verdict: 'unreadable', - scope: receipt.scope, + scope, operationId: receipt.operationId }) } finally { diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts index 6f5abb8cbd14..21d345aaa2c9 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts @@ -10,7 +10,7 @@ export const commandReceiptScopeSchema = z.discriminatedUnion('kind', [ export type CommandReceiptScope = z.infer -/** Sends use global identity; other methods keep the authenticated caller's namespace. */ +/** Sends look across callers; other methods look up only the authenticated caller's row. */ export function commandReceiptScope( callerKey: string, operationIdScope?: 'global' @@ -18,11 +18,7 @@ export function commandReceiptScope( return operationIdScope === 'global' ? { kind: 'global' } : { kind: 'caller', callerKey } } -export function commandReceiptScopeKey(scope: CommandReceiptScope): string { - return scope.kind === 'global' ? 'global' : `caller:${scope.callerKey}` -} - -/** A missing referenced row is spent proof: replay answers unknown, never repeats the effect. */ +/** The pointer locates the result; if its row is gone, the command stays spent: read what remains, else unknown, never rerun. */ export const commandReceiptResultSchema = z.discriminatedUnion('kind', [ z.strictObject({ kind: z.literal('journal-row'), @@ -56,7 +52,6 @@ const rejectionSchema = z }) const receiptIdentity = { - scope: commandReceiptScopeSchema, operationId: z.string().min(1), sessionId: z.string().min(1), callerKey: z.string().min(1), @@ -65,23 +60,18 @@ const receiptIdentity = { acceptedAt: z.int().nonnegative() } -export const commandReceiptSchema = z - .discriminatedUnion('status', [ - z.strictObject({ - ...receiptIdentity, - status: z.literal('accepted'), - result: commandReceiptResultSchema - }), - z.strictObject({ - ...receiptIdentity, - status: z.literal('rejected'), - rejection: rejectionSchema - }) - ]) - .refine( - (receipt) => receipt.scope.kind === 'global' || receipt.scope.callerKey === receipt.callerKey, - { message: 'command caller does not match its scope' } - ) +export const commandReceiptSchema = z.discriminatedUnion('status', [ + z.strictObject({ + ...receiptIdentity, + status: z.literal('accepted'), + result: commandReceiptResultSchema + }), + z.strictObject({ + ...receiptIdentity, + status: z.literal('rejected'), + rejection: rejectionSchema + }) +]) export type CommandReceipt = z.infer @@ -89,7 +79,6 @@ export type CommandReceipt = z.infer export function ensureCommandReceiptsTable(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS agent_session_command_receipts ( - scope TEXT NOT NULL, operation_id TEXT NOT NULL, session_id TEXT NOT NULL REFERENCES agent_session_records(session_id) ON DELETE CASCADE, caller_key TEXT NOT NULL, @@ -99,8 +88,10 @@ CREATE TABLE IF NOT EXISTS agent_session_command_receipts ( result_json TEXT, rejection_json TEXT, accepted_at INTEGER NOT NULL, - PRIMARY KEY (scope, operation_id) + PRIMARY KEY (caller_key, operation_id) ); +CREATE INDEX IF NOT EXISTS agent_session_command_receipts_operation + ON agent_session_command_receipts (operation_id); CREATE INDEX IF NOT EXISTS agent_session_command_receipts_session ON agent_session_command_receipts (session_id); `) 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 index 45ac56472d7f..a9d8824088cf 100644 --- 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 @@ -1,7 +1,7 @@ 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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { JournalHostDatabase } from './journal-host-database' import { closeTestJournalHostDatabases, @@ -9,8 +9,8 @@ import { } from './journal-host-database-test-support' import { commandReceiptScope, - commandReceiptScopeKey, - type CommandReceipt + type CommandReceipt, + type CommandReceiptScope } from './command-receipt-schema' import { insertCommandReceiptIfAbsent, readCommandReceipt } from './command-receipt-table' import { @@ -21,6 +21,7 @@ import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-ses let root: string let database: JournalHostDatabase +const globalScope = commandReceiptScope('caller-1', 'global') beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'orca-command-receipt-table-')) @@ -33,16 +34,29 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -function insert(receipt: CommandReceipt) { - return database.transaction((db) => insertCommandReceiptIfAbsent(db, receipt)) +function insert(receipt: CommandReceipt, scope: CommandReceiptScope = globalScope) { + return database.transaction((db) => insertCommandReceiptIfAbsent(db, scope, receipt)) } -function read(receipt: CommandReceipt) { - return readCommandReceipt(database.db, receipt.scope, receipt.operationId) +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 once and returns the established receipt on a duplicate', () => { + 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 }) @@ -75,22 +89,85 @@ describe('command receipt identity', () => { expect(read(receipt)).toEqual({ verdict: 'readable', receipt }) }) - it('isolates global and caller namespaces without scope string collisions', () => { - const receipts = [ - commandReceiptFixture(), - commandReceiptFixture({ scope: commandReceiptScope('caller-1') }), - commandReceiptFixture({ scope: commandReceiptScope('caller-2'), callerKey: 'caller-2' }), - commandReceiptFixture({ scope: commandReceiptScope('global'), callerKey: 'global' }) - ] - for (const receipt of receipts) { - expect(insert(receipt)).toEqual({ inserted: true }) - 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 time then caller and checks every fingerprint (%i)', + (acceptedAt) => { + const later = cancelReceipt('caller-b', 'turn-b') + const first = { ...cancelReceipt('caller-a', 'turn-a'), acceptedAt } + expect(insert(later, commandReceiptScope(later.callerKey))).toEqual({ inserted: true }) + expect(insert(first, commandReceiptScope(first.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({ scope: commandReceiptScope('caller-2') }) - expect(() => insert(receipt)).toThrow('command caller does not match its scope') + const receipt = commandReceiptFixture() + expect(() => insert(receipt, commandReceiptScope('caller-2'))).toThrow( + 'command caller does not match its scope' + ) expect(read(receipt)).toEqual({ verdict: 'absent' }) }) @@ -100,10 +177,27 @@ describe('command receipt identity', () => { expect(read(receipt)).toEqual({ verdict: 'absent' }) }) - it('requires an effect transaction instead of committing a preliminary reservation', () => { + 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(() => insertCommandReceiptIfAbsent(database.db, receipt)).toThrow(/effect transaction/) + expect(() => insert(receipt)).toThrow(/UNIQUE constraint/i) expect(read(receipt)).toEqual({ verdict: 'absent' }) + expect(database.db.isTransaction).toBe(false) }) }) @@ -161,7 +255,7 @@ describe('unreadable command receipts', () => { database.db.prepare(`UPDATE agent_session_command_receipts SET ${column} = ?`).run(value) const existing = { verdict: 'unreadable', - scope: receipt.scope, + scope: globalScope, operationId: receipt.operationId } expect(read(receipt)).toEqual(existing) @@ -173,15 +267,82 @@ describe('unreadable command receipts', () => { insert(receipt) database.db .prepare(`UPDATE agent_session_command_receipts SET status = 'rejected', result_json = NULL, - rejection_json = ? WHERE scope = ? AND operation_id = ?`) + rejection_json = ? WHERE caller_key = ? AND operation_id = ?`) .run( JSON.stringify({ reference: { code: 'agent_session_operation_invalid', details: { reason: 'madeUp' } } }), - commandReceiptScopeKey(receipt.scope), + 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.ts b/src/main/native-chat/agent-session-journal/command-receipt-table.ts index 03770fb912d1..d224213509f4 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-table.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-table.ts @@ -4,7 +4,7 @@ import { JOURNAL_DB_SCHEMA_VERSION } from './journal-database-schema' import { assertJournalWritable } from './journal-write-guards' import { commandReceiptSchema, - commandReceiptScopeKey, + commandReceiptScopeSchema, type CommandReceipt, type CommandReceiptScope } from './command-receipt-schema' @@ -30,60 +30,101 @@ export type CommandReceiptInsert = } const SELECT_RECEIPT = `SELECT session_id, caller_key, method, fingerprint, status, - result_json, rejection_json, accepted_at FROM agent_session_command_receipts - WHERE scope = ? AND operation_id = ?` + result_json, rejection_json, accepted_at FROM agent_session_command_receipts` + +type CommandReceiptCandidates = + | { verdict: 'readable'; receipts: CommandReceipt[] } + | Extract function parseReceiptJson(value: unknown): unknown { return typeof value === 'string' ? JSON.parse(value) : undefined } -export function readCommandReceipt( +function readCommandReceiptCandidates( db: Database.Database, scope: CommandReceiptScope, operationId: string -): CommandReceiptRead { - const unreadable: CommandReceiptRead = { verdict: 'unreadable', scope, operationId } - // A newer read-only database may not expose this table; that cannot authorize a retry. - if ( - !db - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") - .get('agent_session_command_receipts') - ) { - return unreadable - } - const row = db.prepare(SELECT_RECEIPT).get(commandReceiptScopeKey(scope), operationId) - if (!row) { - return { verdict: 'absent' } +): CommandReceiptCandidates { + const unreadable: Extract = { + verdict: 'unreadable', + scope, + operationId } try { + // A newer read-only database may not expose this table; that cannot authorize a retry. + if ( + !db + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") + .get('agent_session_command_receipts') + ) { + return unreadable + } + const rows = + scope.kind === 'caller' + ? db + .prepare(`${SELECT_RECEIPT} WHERE caller_key = ? AND operation_id = ?`) + .all(scope.callerKey, operationId) + : db + .prepare(`${SELECT_RECEIPT} WHERE operation_id = ? ORDER BY accepted_at, caller_key`) + .all(operationId) + const receipts: CommandReceipt[] = [] + for (const row of rows) { + if ( + (row.status === 'accepted' && row.rejection_json !== null) || + (row.status === 'rejected' && row.result_json !== null) + ) { + return unreadable + } + const parsed = commandReceiptSchema.safeParse({ + operationId, + sessionId: row.session_id, + callerKey: row.caller_key, + method: row.method, + fingerprint: row.fingerprint, + status: row.status, + acceptedAt: row.accepted_at, + ...(row.status === 'accepted' + ? { result: parseReceiptJson(row.result_json) } + : { rejection: parseReceiptJson(row.rejection_json) }) + }) + if (!parsed.success) { + return unreadable + } + receipts.push(parsed.data) + } + return { verdict: 'readable', receipts } + } catch (error) { if ( - (row.status === 'accepted' && row.rejection_json !== null) || - (row.status === 'rejected' && row.result_json !== null) + error instanceof RangeError || + error instanceof SyntaxError || + (error instanceof Error && + 'code' in error && + error.code === 'ERR_SQLITE_ERROR' && + /^no such (?:column|table):/.test(error.message)) ) { return unreadable } - const parsed = commandReceiptSchema.safeParse({ - scope, - operationId, - sessionId: row.session_id, - callerKey: row.caller_key, - method: row.method, - fingerprint: row.fingerprint, - status: row.status, - acceptedAt: row.accepted_at, - ...(row.status === 'accepted' - ? { result: parseReceiptJson(row.result_json) } - : { rejection: parseReceiptJson(row.rejection_json) }) - }) - return parsed.success ? { verdict: 'readable', receipt: parsed.data } : unreadable - } catch { - return unreadable + throw error + } +} + +export function readCommandReceipt( + db: Database.Database, + scope: CommandReceiptScope, + operationId: string +): CommandReceiptRead { + const candidates = readCommandReceiptCandidates(db, scope, operationId) + if (candidates.verdict === 'unreadable') { + return candidates } + const receipt = candidates.receipts[0] + return receipt ? { verdict: 'readable', receipt } : { verdict: 'absent' } } /** The caller owns the effect's transaction; this function never reserves or commits on its own. */ export function insertCommandReceiptIfAbsent( db: Database.Database, + scope: CommandReceiptScope, receipt: CommandReceipt ): CommandReceiptInsert { assertJournalWritable( @@ -91,40 +132,41 @@ export function insertCommandReceiptIfAbsent( receipt.sessionId ) if (!db.isTransaction) { - throw new Error('a command receipt must be written inside its effect transaction') + throw new Error('a command receipt must be written inside an open transaction') } const written = commandReceiptSchema.parse(receipt) - const inserted = db - .prepare(`INSERT INTO agent_session_command_receipts - (scope, operation_id, session_id, caller_key, method, fingerprint, status, - result_json, rejection_json, accepted_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(scope, operation_id) DO NOTHING`) - .run( - commandReceiptScopeKey(written.scope), - written.operationId, - written.sessionId, - written.callerKey, - written.method, - written.fingerprint, - written.status, - written.status === 'accepted' ? JSON.stringify(written.result) : null, - written.status === 'rejected' ? JSON.stringify(written.rejection) : null, - written.acceptedAt - ) - if (Number(inserted.changes) === 1) { - return { inserted: true } - } - const existing = readCommandReceipt(db, written.scope, written.operationId) - if (existing.verdict === 'absent') { - throw new Error('an existing command receipt disappeared inside its transaction') + const claim = commandReceiptScopeSchema.parse(scope) + if (claim.kind === 'caller' && claim.callerKey !== written.callerKey) { + throw new Error('command caller does not match its scope') } - if (existing.verdict === 'unreadable') { - return { inserted: false, reason: 'unreadable', existing } + const candidates = readCommandReceiptCandidates(db, claim, written.operationId) + if (candidates.verdict === 'unreadable') { + return { inserted: false, reason: 'unreadable', existing: candidates } } - return { - inserted: false, - existing, - reason: existing.receipt.fingerprint === written.fingerprint ? 'duplicate' : 'conflict' + const duplicate = candidates.receipts.find( + (candidate) => candidate.fingerprint === written.fingerprint + ) + const existing = duplicate ?? candidates.receipts[0] + if (existing) { + return { + inserted: false, + existing: { verdict: 'readable', receipt: existing }, + reason: duplicate ? 'duplicate' : 'conflict' + } } + db.prepare(`INSERT INTO agent_session_command_receipts + (operation_id, session_id, caller_key, method, fingerprint, status, + result_json, rejection_json, accepted_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run( + written.operationId, + written.sessionId, + written.callerKey, + written.method, + written.fingerprint, + written.status, + written.status === 'accepted' ? JSON.stringify(written.result) : null, + written.status === 'rejected' ? JSON.stringify(written.rejection) : null, + written.acceptedAt + ) + return { inserted: true } } diff --git a/src/main/native-chat/agent-session-journal/command-receipt-test-support.ts b/src/main/native-chat/agent-session-journal/command-receipt-test-support.ts index 980d782e570d..b26b8e1f45c6 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-test-support.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-test-support.ts @@ -1,13 +1,12 @@ import type Database from '../../sqlite/sync-database' import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' import { writeAgentSessionStoreRows } from '../../runtime/agent-session-record-rows' -import { commandReceiptScope, type CommandReceipt } from './command-receipt-schema' +import type { CommandReceipt } from './command-receipt-schema' type AcceptedReceipt = Extract export function commandReceiptFixture(overrides: Partial = {}): AcceptedReceipt { return { - scope: commandReceiptScope('caller-1', 'global'), operationId: 'operation-1', sessionId: 'session-1', callerKey: 'caller-1', 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 index 425f2dbf6060..7c98aa5c7168 100644 --- 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 @@ -20,6 +20,7 @@ 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, type CommandReceiptResult } from './command-receipt-schema' import { commandReceiptFixture, writeCommandReceiptTestRecord @@ -32,6 +33,7 @@ import { let root: string let database: JournalHostDatabase const receipt = commandReceiptFixture() +const scope = commandReceiptScope(receipt.callerKey, 'global') const identity = { sessionId: 'session-1', workspaceId: 'workspace-1', @@ -72,12 +74,12 @@ function storedRows() { } function readReceipt() { - return readCommandReceipt(database.db, receipt.scope, receipt.operationId) + 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(receipt) + const hook = buildCommandReceiptTransaction(scope, receipt) expect(() => runJournalTransaction(database.db, (db) => { insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) @@ -93,7 +95,7 @@ describe('command receipt transaction hook', () => { 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[] = [] - let result = receipt.result + let result: CommandReceiptResult = { kind: 'journal-row', epoch: 'epoch-0', sequence: 99 } const writer = new JournalRowWriter({ sessionId: identity.sessionId, now: () => 1000, @@ -101,7 +103,7 @@ describe('command receipt transaction hook', () => { database: () => database, readOnly: () => database.readOnly, highestFence: () => 0, - nextSequence: () => 1, + nextSequence: () => 7, commit: (row) => committed.push(row) }) await writer.enqueue( @@ -109,21 +111,24 @@ describe('command receipt transaction hook', () => { (_db, row) => { result = { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } }, - buildCommandReceiptTransaction(() => ({ ...receipt, result })) + buildCommandReceiptTransaction(scope, () => ({ ...receipt, result })) ) expect(committed).toHaveLength(1) - expect(storedRows()).toEqual([{ seq: 1 }]) - expect(readReceipt()).toEqual({ verdict: 'readable', receipt }) + 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, receipt)) + 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({ + const hook = buildCommandReceiptTransaction(scope, { ...receipt, fingerprint: reason === 'conflict' ? 'changed' : receipt.fingerprint }) @@ -146,7 +151,7 @@ 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, receipt) + insertCommandReceiptIfAbsent(db, scope, receipt) deleteJournalEpochRows(db, identity.sessionId, 'epoch-1') }) expect(storedRows()).toEqual([]) @@ -156,7 +161,7 @@ describe('command receipts across epoch replacement', () => { it('survives the actual replacement transaction without transferring or copying results', () => { database.transaction((db) => { insertJournalRow(db, identity.sessionId, epochRow(1, 1000)) - insertCommandReceiptIfAbsent(db, receipt) + insertCommandReceiptIfAbsent(db, scope, receipt) }) replaceJournalEpoch({ database, @@ -173,7 +178,9 @@ describe('command receipts across epoch replacement', () => { { epoch: 'epoch-2' } ]) expect(readReceipt()).toEqual({ verdict: 'readable', receipt }) - expect(database.transaction((db) => insertCommandReceiptIfAbsent(db, receipt))).toMatchObject({ + 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.ts b/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts index 9f747d49e63d..c4b3330cc288 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts @@ -1,4 +1,4 @@ -import type { CommandReceipt } from './command-receipt-schema' +import type { CommandReceipt, CommandReceiptScope } from './command-receipt-schema' import { insertCommandReceiptIfAbsent, type CommandReceiptInsert } from './command-receipt-table' import type { JournalOperationReceipt } from './journal-row-writer' @@ -13,12 +13,14 @@ export class CommandReceiptExistsError extends Error { } export function buildCommandReceiptTransaction( + scope: CommandReceiptScope, receipt: CommandReceipt | (() => CommandReceipt) ): JournalOperationReceipt { return { write: (db) => { const result = insertCommandReceiptIfAbsent( db, + scope, typeof receipt === 'function' ? receipt() : receipt ) if (!result.inserted) { From e31475e2c300d496aca23cb8885056a6c2565c77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:06:17 -0700 Subject: [PATCH 03/11] fix(native-chat): order receipt lookups by insertion and key by operation id Global lookups now take rows in insertion order, so a read and an insert agree on which receipt answers an id regardless of the host clock. The primary key leads with the operation id, so its own index serves both lookups. --- .../command-receipt-schema.test.ts | 28 +++++++++++-------- .../command-receipt-schema.ts | 4 +-- .../command-receipt-table.test.ts | 8 +++--- .../command-receipt-table.ts | 4 +-- 4 files changed, 23 insertions(+), 21 deletions(-) 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 index 2fab511614be..6c5fbcfa452f 100644 --- 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 @@ -46,14 +46,14 @@ function hasReceiptTable(db: Database.Database): boolean { } describe('command receipt schema at writable open', () => { - it('creates the caller/id primary key and lookup indexes on a fresh host database', () => { + 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: 2 }), + expect.objectContaining({ name: 'operation_id', pk: 1 }), expect.objectContaining({ name: 'session_id', pk: 0 }), - expect.objectContaining({ name: 'caller_key', pk: 1 }), + 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 }), @@ -61,14 +61,20 @@ describe('command receipt schema at writable open', () => { expect.objectContaining({ name: 'rejection_json', pk: 0 }), expect.objectContaining({ name: 'accepted_at', pk: 0 }) ]) - for (const [index, column] of [ - ['operation', 'operation_id'], - ['session', 'session_id'] - ] as const) { - expect( - database.db.prepare(`PRAGMA index_info(agent_session_command_receipts_${index})`).all() - ).toEqual([expect.objectContaining({ name: column })]) - } + 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([ diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts index 21d345aaa2c9..dfadddbb063c 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts @@ -88,10 +88,8 @@ CREATE TABLE IF NOT EXISTS agent_session_command_receipts ( result_json TEXT, rejection_json TEXT, accepted_at INTEGER NOT NULL, - PRIMARY KEY (caller_key, operation_id) + PRIMARY KEY (operation_id, caller_key) ); -CREATE INDEX IF NOT EXISTS agent_session_command_receipts_operation - ON agent_session_command_receipts (operation_id); CREATE INDEX IF NOT EXISTS agent_session_command_receipts_session ON agent_session_command_receipts (session_id); `) 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 index a9d8824088cf..5fb26b71e1dc 100644 --- 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 @@ -143,12 +143,12 @@ describe('command receipt identity', () => { }) it.each([500, 1000])( - 'orders global candidates by time then caller and checks every fingerprint (%i)', + 'orders global candidates by insertion and checks every fingerprint (%i)', (acceptedAt) => { - const later = cancelReceipt('caller-b', 'turn-b') - const first = { ...cancelReceipt('caller-a', 'turn-a'), acceptedAt } - expect(insert(later, commandReceiptScope(later.callerKey))).toEqual({ inserted: true }) + 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, diff --git a/src/main/native-chat/agent-session-journal/command-receipt-table.ts b/src/main/native-chat/agent-session-journal/command-receipt-table.ts index d224213509f4..3b8c2d38bc56 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-table.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-table.ts @@ -64,9 +64,7 @@ function readCommandReceiptCandidates( ? db .prepare(`${SELECT_RECEIPT} WHERE caller_key = ? AND operation_id = ?`) .all(scope.callerKey, operationId) - : db - .prepare(`${SELECT_RECEIPT} WHERE operation_id = ? ORDER BY accepted_at, caller_key`) - .all(operationId) + : db.prepare(`${SELECT_RECEIPT} WHERE operation_id = ? ORDER BY rowid`).all(operationId) const receipts: CommandReceipt[] = [] for (const row of rows) { if ( From 7a3b58c78bf5cbfcfc2116c4497a93cc44d8ce18 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:18:52 -0700 Subject: [PATCH 04/11] fix(native-chat): accept sends through their own receipt Send (direct, queued, steer) and /compact no longer commit a separate ledger reservation before the message is saved. Their command receipt is inserted-if-absent in the same transaction as the submission or queued draft; a repeated id replays from it, a changed request conflicts. Operation ids are checked for syntax only, so age, clock skew, pruning and the reservation's own storage can no longer refuse a send. The ledger success row they already co-committed stays as a temporary insert-if-absent write so families not yet switched keep seeing these ids; it is never read on this path. --- .../command-receipt-schema.ts | 4 + .../command-receipt-table.test.ts | 9 + .../command-receipt-transaction.test.ts | 14 +- .../command-receipt-transaction.ts | 7 +- .../journal-row-writer.ts | 37 ++- .../agent-session-journal/journal-store.ts | 18 +- .../journal-submission-fold.ts | 47 ++-- .../journal-submission-writer.ts | 9 + ...agent-session-attachment-admission.test.ts | 27 ++ ...uctured-agent-session-command-admission.ts | 167 ++++++++++++ ...tructured-agent-session-command-receipt.ts | 121 +++++++++ ...ctured-agent-session-mutation-admission.ts | 13 +- ...ructured-agent-session-mutation-context.ts | 23 +- ...structured-agent-session-mutation-plans.ts | 26 +- ...agent-session-operation-settlement.test.ts | 15 +- ...ured-agent-session-operation-settlement.ts | 61 +---- ...tured-agent-session-queued-command.test.ts | 39 +++ ...-agent-session-queued-repeat-press.test.ts | 103 +++++++- .../structured-agent-session-queued-send.ts | 6 +- ...ctured-agent-session-resend-answer.test.ts | 247 +++++++++++++++++- ...gent-session-resend-ledger-refusal.test.ts | 76 ++++-- ...red-agent-session-send-preparation.test.ts | 12 +- .../structured-agent-session-send.test.ts | 10 +- .../structured-agent-session-turns.ts | 7 + ...structured-conversation-compaction.test.ts | 11 +- .../agent-session-operation-admission.ts | 42 +++ .../agent-session-operation-receipt.ts | 25 ++ src/main/runtime/agent-session-record-rows.ts | 18 ++ .../runtime/agent-session-record-store.ts | 21 +- .../agent-session-store-transactions.ts | 22 +- .../versioned-agent-session-wire.ts | 6 +- 31 files changed, 1052 insertions(+), 191 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts create mode 100644 src/main/runtime/agent-session-operation-receipt.ts diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts index dfadddbb063c..cc8aab79eb18 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts @@ -25,6 +25,10 @@ export const commandReceiptResultSchema = z.discriminatedUnion('kind', [ epoch: z.string().min(1), sequence: z.int().positive() }), + z.strictObject({ + kind: z.literal('queued-draft'), + messageId: z.string().min(1) + }), z.strictObject({ kind: z.literal('no-op'), outcome: z.discriminatedUnion('kind', [ 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 index 5fb26b71e1dc..4f0da005da75 100644 --- 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 @@ -202,6 +202,14 @@ describe('command receipt identity', () => { }) 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 }, @@ -242,6 +250,7 @@ describe('unreadable command receipts', () => { ['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', ''], 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 index 7c98aa5c7168..cef976df0198 100644 --- 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 @@ -20,7 +20,7 @@ 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, type CommandReceiptResult } from './command-receipt-schema' +import { commandReceiptScope } from './command-receipt-schema' import { commandReceiptFixture, writeCommandReceiptTestRecord @@ -95,7 +95,6 @@ describe('command receipt transaction hook', () => { 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[] = [] - let result: CommandReceiptResult = { kind: 'journal-row', epoch: 'epoch-0', sequence: 99 } const writer = new JournalRowWriter({ sessionId: identity.sessionId, now: () => 1000, @@ -108,10 +107,13 @@ describe('command receipt transaction hook', () => { }) await writer.enqueue( epochRow, - (_db, row) => { - result = { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } - }, - buildCommandReceiptTransaction(scope, () => ({ ...receipt, result })) + 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(storedRows()).toEqual([{ seq: 7 }]) diff --git a/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts b/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts index c4b3330cc288..cc4e228c2413 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-transaction.ts @@ -1,6 +1,7 @@ import type { CommandReceipt, CommandReceiptScope } from './command-receipt-schema' import { insertCommandReceiptIfAbsent, type CommandReceiptInsert } from './command-receipt-table' import type { JournalOperationReceipt } from './journal-row-writer' +import type { JournalRow } from './journal-row-schema' type CommandReceiptNotInserted = Extract @@ -14,14 +15,14 @@ export class CommandReceiptExistsError extends Error { export function buildCommandReceiptTransaction( scope: CommandReceiptScope, - receipt: CommandReceipt | (() => CommandReceipt) + receipt: CommandReceipt | ((row?: JournalRow) => CommandReceipt) ): JournalOperationReceipt { return { - write: (db) => { + write: (db, row) => { const result = insertCommandReceiptIfAbsent( db, scope, - typeof receipt === 'function' ? receipt() : receipt + typeof receipt === 'function' ? receipt(row) : receipt ) if (!result.inserted) { throw new CommandReceiptExistsError(result) diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 60db6e0cbd1e..0e3ae51bccd2 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -5,20 +5,30 @@ import type { AgentJournalCursor } from '../../../shared/agent-session-journal-t import type { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' import type { JournalWriteBody } from './journal-write-queue' +import { readJournalRowsAfterCursor } from './journal-open' /** Runs between BEGIN IMMEDIATE and COMMIT, on the SAME connection as the row * insert; a throw rolls the whole append back. Synchronous by construction so * nothing can interleave inside the transaction. */ export type JournalRowTransactionHook = (db: Database.Database, row: JournalRow) => void -/** An operation's ledger answer, committed with the journal write that makes it true: `write` runs +/** An operation's receipt, committed with the journal write that makes it true: `write` runs * inside that transaction on the same connection, `committed` synchronously right after its * COMMIT and never after a rollback. */ export type JournalOperationReceipt = { - write: (db: Database.Database) => void + write: (db: Database.Database, row?: JournalRow) => void committed: () => void } +export function composeJournalOperationReceipts( + ...receipts: JournalOperationReceipt[] +): JournalOperationReceipt { + return { + write: (db, row) => receipts.forEach((receipt) => receipt.write(db, row)), + committed: () => receipts.forEach((receipt) => receipt.committed()) + } +} + export type JournalRowWriterDeps = { sessionId: string now: () => number @@ -41,6 +51,27 @@ const BOOKKEEPING_SAVEPOINT = 'journal_row_bookkeeping' export class JournalRowWriter { constructor(private readonly deps: JournalRowWriterDeps) {} + /** Another caller may acknowledge an existing submission without accepting it twice. */ + commitReceipt(cursor: AgentJournalCursor, receipt: JournalOperationReceipt): Promise { + return this.deps.serialize(() => { + assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) + this.deps.database().transaction((db) => { + const row = readJournalRowsAfterCursor( + db, + this.deps.sessionId, + cursor.epoch, + cursor.sequence - 1, + 1 + )[0] + if (!row || row.seq !== cursor.sequence || row.kind !== 'submission') { + throw new Error('accepted submission is missing') + } + receipt.write(db, row) + }) + receipt.committed() + }) + } + enqueue( build: (seq: number, ts: number) => JournalRow, hook?: JournalRowTransactionHook, @@ -55,7 +86,7 @@ export class JournalRowWriter { this.deps.database().transaction((db) => { insertJournalRow(db, this.deps.sessionId, row) hook?.(db, row) - receipt?.write(db) + receipt?.write(db, row) this.runBookkeeping(db, row) }) } catch (error) { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 06bdfba25fea..81d1fc45a469 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -28,7 +28,8 @@ import { import type { JournalReplacementItem } from './journal-epoch-replacement' import { readJournalSince } from './journal-cursor' import type { JournalHostDatabase } from './journal-host-database' -import { journalRowsAfterReader, type JournalLoad } from './journal-open' +import { journalRowsAfterReader, replayJournal, type JournalLoad } from './journal-open' +import { failLoadOnUnloadableJournal } from './journal-open-failure' import { markJournalPendingSubmissionsUnknown, rejectJournalPendingSubmissions, @@ -260,6 +261,18 @@ export class AgentSessionJournal { return this.queue.readInOrder(read) } + /** Re-derive the fold after a committed append failed to publish; never writes or notifies. */ + refreshCommittedState(): Promise { + return this.readInOrder(() => { + const loaded = replayJournal(this.database.db, this.identity.sessionId) + if (!loaded) { + throw new Error('committed journal is missing') + } + failLoadOnUnloadableJournal(this.identity.sessionId, loaded) + this.adoptLoadedJournal(loaded) + }) + } + readSince(cursor: AgentJournalCursor, limit?: number): JournalReadSince { const { sessionId } = this.identity const rowsAfter = journalRowsAfterReader(this.database.db, sessionId, this.state.epoch, limit) @@ -333,6 +346,9 @@ export class AgentSessionJournal { return this.submissionWriter.append(input, consume, receipt) } + commitSubmissionReceipt: JournalSubmissionWriter['commitReceipt'] = (id, receipt) => + this.submissionWriter.commitReceipt(id, receipt) + /** A dispatch transition (`JournalSubmissionWriter.resolveDispatch`). */ resolveDispatch( input: ResolveDispatchInput, diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 7b03b4f457f8..6ae93f903e45 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -16,26 +16,7 @@ export function applyJournalSubmission( state: JournalReducerState, row: Extract ): void { - state.submissions.set(row.clientMessageId, { - clientMessageId: row.clientMessageId, - fence: row.fence, - payloadFingerprint: row.payloadFingerprint, - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: row.ts, - resolvedAt: null, - submittedSequence: row.seq, - ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), - // A malformed stored link is dropped, never the row. - ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 - ? { queuedMessageId: row.queuedMessageId } - : {}), - ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}), - // Kept as written, a newer build's kind too; an undecodable one as an empty kind, so neither - // reads as a row without one. - ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}) - }) + state.submissions.set(row.clientMessageId, journalSubmissionFromRow(row)) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. const turnScope = row.handoverRecorded @@ -147,3 +128,29 @@ function storedSourceKind(stored: unknown): string { ? stored.kind : '' } + +/** The acceptance projection is shared by the fold and a reply after failed publication. */ +export function journalSubmissionFromRow( + row: Extract +): AgentJournalSubmission { + return { + clientMessageId: row.clientMessageId, + fence: row.fence, + payloadFingerprint: row.payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: row.ts, + resolvedAt: null, + submittedSequence: row.seq, + ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), + // A malformed stored link is dropped, never the row. + ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 + ? { queuedMessageId: row.queuedMessageId } + : {}), + ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}), + // Kept as written, a newer build's kind too; an undecodable one as an empty kind, so neither + // reads as a row without one. + ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}) + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts index 1fc0ac21d0da..d5ff454b817e 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -31,6 +31,15 @@ export type JournalSubmissionWriterDeps = { export class JournalSubmissionWriter { constructor(private readonly deps: JournalSubmissionWriterDeps) {} + commitReceipt(clientMessageId: string, receipt: JournalOperationReceipt): Promise { + const state = this.deps.state() + const sequence = state.submissions.get(clientMessageId)?.submittedSequence + if (sequence === undefined) { + throw new Error('accepted submission has no journal position') + } + return this.deps.rowWriter.commitReceipt({ epoch: state.epoch, sequence }, receipt) + } + /** * Write-ahead submission row. It is durable before the caller dispatches * anything, and it doubles as the optimistic user bubble so an accepted echo diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts index 9228f6f0743f..ceecca665eae 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attachment-admission.test.ts @@ -143,3 +143,30 @@ describe('attachments at send admission', () => { expect(result).toMatchObject({ ok: true, value: { submission: expect.anything() } }) }) }) + +it.each([undefined, 'queue-if-active'] as const)( + 're-evaluates an unaccepted attachment refusal after the file returns (%s)', + async (delivery) => { + if (delivery) { + await rig.workingSend() + } + const body = textWith(storedPath('notes.txt')) + const clientOperationId = hostTestOperationId() + const fields = { body, ...(delivery ? { delivery } : {}) } + const params = { + envelope: rig.envelope(fields, 'agentSession.send', clientOperationId), + ...fields, + userSend: true as const + } + expect(await rig.host.send(CALLER, params)).toMatchObject(EXPIRED) + expect(rig.store.readCommandReceipt({ kind: 'global' }, clientOperationId)).toEqual({ + verdict: 'absent' + }) + expect( + rig.store.listOperationRows().find((row) => row.operationId === clientOperationId) + ).toBeUndefined() + await storeUpload('notes.txt') + expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: false }) + expect(await clientSend(body, delivery)).toMatchObject({ ok: true, replayed: false }) + } +) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts new file mode 100644 index 000000000000..3f114f64b4e9 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts @@ -0,0 +1,167 @@ +import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' +import { agentSessionLedgerRefusal } from '../../../shared/agent-session-mutation-envelope' +import { + agentSessionRefusalFromReference, + isAgentSessionRefusalError +} from '../../../shared/agent-session-wire-refusals' +import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger' +import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire' +import { commandReceiptScope } from '../agent-session-journal/command-receipt-schema' +import type { CommandReceipt } from '../agent-session-journal/command-receipt-schema' +import type { ExistingCommandReceipt } from '../agent-session-journal/command-receipt-table' +import { CommandReceiptExistsError } from '../agent-session-journal/command-receipt-transaction' +import { + AGENT_SESSION_NOT_ATTACHED, + refuseAgentSessionMutation, + type AgentSessionMutationRequest +} from './structured-agent-session-mutation-admission' +import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' +import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' +import { + agentSessionOperationOutcomeUnknown, + resolveAgentSessionReplayOutcome +} from './structured-agent-session-replay-outcome' + +/** Submission-accepting plans use their effect's receipt, never a ledger reservation. */ +export async function admitCommandReceiptMutation( + request: AgentSessionMutationRequest, + hostFingerprint: string +): Promise> { + const { store, envelope, plan, callerKey } = request + if (parseAgentSessionOperationTimestamp(envelope.clientOperationId) === null) { + return refuseAgentSessionMutation( + agentSessionLedgerRefusal(envelope, { + decision: 'refused', + code: 'agent_session_operation_invalid', + details: { reason: 'operationIdInvalid' } + }) + ) + } + const record = store.getRecord(envelope.sessionId) + if (!record) { + return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) + } + let read + try { + read = store.readCommandReceipt( + commandReceiptScope(callerKey, plan.operationIdScope), + envelope.clientOperationId + ) + } catch { + return unknown(request) + } + if (read.verdict !== 'absent') { + return answerCommandReceipt(request, hostFingerprint, read) + } + const prepared = await request.prepareSession?.('admit', record) + if (prepared && !prepared.ok) { + return prepared + } + const journal = request.journal() + const current = store.getRecord(envelope.sessionId) + if (!journal || !current) { + return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) + } + // These plans write only to the conversation, whose journal enforces the execution-host fence. + const context = mutationTurnContext(request, journal, current) + try { + const outcome = await runSettledAgentSessionMutation({ + store, + operationCallerKey: callerKey, + envelope, + plan, + context + }) + return outcome.ok + ? { + ok: true, + replayed: false, + fence: context.fence, + cursor: journal.cursor(), + value: outcome.value + } + : refuseAgentSessionMutation(outcome.refusal) + } catch (error) { + if (error instanceof CommandReceiptExistsError) { + return answerCommandReceipt(request, hostFingerprint, error.result.existing) + } + if (isAgentSessionRefusalError(error)) { + return refuseAgentSessionMutation(error.refusal) + } + throw error + } +} + +async function answerCommandReceipt( + request: AgentSessionMutationRequest, + hostFingerprint: string, + read: ExistingCommandReceipt +): Promise> { + const { envelope, plan } = request + if (read.verdict === 'unreadable') { + return unknown(request) + } + const { receipt } = read + if (receipt.fingerprint !== hostFingerprint) { + return refuseAgentSessionMutation( + agentSessionLedgerRefusal(envelope, { + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + }) + ) + } + if (receipt.status === 'rejected') { + return refuseAgentSessionMutation( + agentSessionRefusalFromReference( + receipt.rejection.reference, + receipt.rejection.message ?? 'Operation was already refused.' + ) + ) + } + const record = request.store.getRecord(envelope.sessionId) + if (!record) { + return unknown(request) + } + const prepared = await request.prepareSession?.('replay', record) + if (prepared && !prepared.ok) { + return prepared + } + const journal = request.journal() + const current = request.store.getRecord(envelope.sessionId) + if (!journal || !current) { + return unknown(request) + } + const context = mutationTurnContext(request, journal, current) + const outcome = commandReceiptOutcome(receipt) + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome, + reconstruct: () => plan.replay(context, outcome), + rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), + recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState + }) + // A missing result cannot undo durable acceptance, even if a later plan allows missing replays. + if (replay.decision !== 'replay') { + return replay.decision === 'refuse' + ? refuseAgentSessionMutation(replay.refusal) + : unknown(request) + } + return { + ok: true, + replayed: true, + fence: context.fence, + cursor: journal.cursor(), + value: replay.value + } +} + +function commandReceiptOutcome(receipt: CommandReceipt): AgentSessionOperationOutcome { + return { status: 'succeeded', sessionId: receipt.sessionId } +} + +function unknown(request: AgentSessionMutationRequest) { + return refuseAgentSessionMutation( + agentSessionOperationOutcomeUnknown(request.envelope.clientOperationId) + ) +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts new file mode 100644 index 000000000000..e5d03867dde6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts @@ -0,0 +1,121 @@ +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import type { + AgentSessionSendResult, + AgentSessionMutationEnvelope +} from '../../../shared/agent-session-wire' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { commandReceiptScope } from '../agent-session-journal/command-receipt-schema' +import { buildCommandReceiptTransaction } from '../agent-session-journal/command-receipt-transaction' +import { journalSubmissionFromRow } from '../agent-session-journal/journal-submission-fold' +import { composeJournalOperationReceipts } from '../agent-session-journal/journal-row-writer' +import type { MutationPlan } from './structured-agent-session-mutation-plans' +import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' + +/** Acceptance identity and compatibility bookkeeping share the submission or draft's commit. */ +export async function runCommandReceiptMutation(input: { + store: AgentSessionRecordStore + operationCallerKey: string + envelope: AgentSessionMutationEnvelope + plan: MutationPlan + context: AgentSessionTurnContext +}): Promise> { + const { store, operationCallerKey, envelope, plan, context } = input + const fingerprint = computeAgentSessionPayloadFingerprint({ + method: plan.method, + sessionId: envelope.sessionId, + fields: plan.fields + }) + const outcome = { status: 'succeeded' as const, sessionId: envelope.sessionId } + let acceptedSend: AgentSessionSendResult | undefined + const receipt = composeJournalOperationReceipts( + buildCommandReceiptTransaction( + commandReceiptScope(operationCallerKey, plan.operationIdScope), + (row) => { + if (row) { + if (row.kind !== 'submission') { + throw new Error('Send acceptance requires a submission') + } + acceptedSend = { + clientMessageId: envelope.clientOperationId, + submission: journalSubmissionFromRow(row) + } + } else { + const draft = context.journal.queuedMessages.get(envelope.clientOperationId) + if (!draft) { + throw new Error('queued Send acceptance requires a draft') + } + acceptedSend = { + clientMessageId: envelope.clientOperationId, + queued: { messageId: draft.messageId, position: draft.position, state: draft.state } + } + } + return { + operationId: envelope.clientOperationId, + sessionId: envelope.sessionId, + callerKey: operationCallerKey, + method: plan.method, + fingerprint, + status: 'accepted', + acceptedAt: context.now(), + result: row + ? { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } + : { kind: 'queued-draft', messageId: envelope.clientOperationId } + } + } + ), + store.operationOutcomeReceipt({ + callerKey: operationCallerKey, + operationId: envelope.clientOperationId, + fingerprint, + now: context.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), + outcome + }) + ) + let committed = false + try { + const ran = await plan.run({ + ...context, + operationReceipt: { + write: receipt.write, + committed: () => { + committed = true + receipt.committed() + } + } + }) + if (!committed || ran.ok) { + return ran + } + } catch (error) { + if (!committed) { + throw error + } + } + context.logger.warn('publishing an accepted command failed; answering its committed result', { + scope: 'command-receipt-publication', + sessionId: envelope.sessionId, + operationId: envelope.clientOperationId + }) + await context.journal.refreshCommittedState().catch((error: unknown) => { + context.logger.warn('reloading an accepted command failed', { + scope: 'command-receipt-reload', + sessionId: envelope.sessionId, + error + }) + }) + const replay = resolveAgentSessionReplayOutcome({ + operationId: envelope.clientOperationId, + outcome, + reconstruct: () => plan.replay(context, outcome, acceptedSend), + recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState + }) + return replay.decision === 'replay' + ? { ok: true, value: replay.value } + : { ok: false, refusal: replay.decision === 'refuse' ? replay.refusal : unreachableReplay() } +} + +function unreachableReplay(): never { + throw new Error('an accepted command cannot run again') +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index 3cb9762cf39f..d6d2484aac4f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -1,9 +1,8 @@ -// The one route every mutating agent-session call takes: recompute the -// fingerprint, admit through the durable operation ledger, check the lease, then -// run the plan. It lives outside the host so that no method can quietly grow its -// own admission rules by sitting next to the call site. +// The one route every mutating agent-session call takes: recompute the fingerprint, +// dispatch submission-accepting plans to their command receipt, otherwise admit +// through the ledger and check the lease. Methods cannot grow their own admission rules. // -// Admission is two-phase for a call that brings a `prepareSession`. The ledger's +// Ledger-backed admission is two-phase when a call brings `prepareSession`. The ledger's // answer comes first and places nothing. An id it refuses is answered then, with // nothing opened; so is a recorded id that settled refused. Any other recorded id // is answered after the plan's own preparation for a replay (a send's only opens @@ -52,6 +51,7 @@ import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import type { StructuredAgentRegistry } from './structured-agent-registry' +import { admitCommandReceiptMutation } from './structured-agent-session-command-admission' // The code is shared with the client so a read that refuses this way can be told apart from a // transcript that failed to load; the two must never drift apart. @@ -106,6 +106,9 @@ export async function admitAndRunAgentSessionMutation( if (conflict) { return refuseAgentSessionMutation(conflict) } + if (plan.settlesWithWrite) { + return admitCommandReceiptMutation(request, hostFingerprint) + } if (request.prepareSession) { const ledger = request.store.evaluateMutationOperation({ callerKey: request.callerKey, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 099b356cb5e3..15db20dfae22 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -59,8 +59,8 @@ export function mutateStructuredAgentSession( plan: MutationPlan, prepareSession?: AgentSessionMutationRequest['prepareSession'] ): Promise> { - return context.serialize(envelope.sessionId, () => - admitAndRunAgentSessionMutation({ + return context.serialize(envelope.sessionId, async () => { + const result = await admitAndRunAgentSessionMutation({ store: context.deps.store, adapter: context.deps.adapter, agents: context.deps.agents, @@ -74,5 +74,22 @@ export function mutateStructuredAgentSession( providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, now: () => context.now() }) - ) + if ( + result.ok && + !result.replayed && + plan.settlesWithWrite && + !(typeof result.value === 'object' && result.value !== null && 'queued' in result.value) + ) { + try { + context.wakeDelivery(envelope.sessionId) + } catch (error) { + context.deps.logger.warn('waking delivery after acceptance failed', { + scope: 'command-receipt-delivery', + sessionId: envelope.sessionId, + error + }) + } + } + return result + }) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 4f30c3f1f00d..e2af5846b778 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -1,11 +1,7 @@ // One plan per mutating method: what it fingerprints, what it does, and how its // answer is rebuilt on a replay. // -// The replay half matters more than it looks. The ledger records only that an -// operation happened, so the durable answer usually comes back out of the -// journal. Send is fail-closed: admission alone cannot prove non-delivery, so -// its success commits with the row that accepts it, and a row still pending is -// one that wrote nothing. +// The receipt proves acceptance; the journal projects its current answer. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { @@ -52,14 +48,16 @@ export type MutationPlan = { /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ runsWithoutLedgerRow?: true run: (ctx: AgentSessionTurnContext) => Promise> - replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null + replay: ( + ctx: AgentSessionTurnContext, + outcome: AgentSessionOperationOutcome, + acceptedSend?: AgentSessionSendResult + ) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean recoverUnknownFromDurableState?: boolean } & ( | { - /** Its success is the row its run writes, so it commits in that row's transaction - * (`AgentSessionTurnContext.operationReceipt`): a row left pending wrote nothing. That - * success is fixed before the value exists, so it records no `settledOutcome`. */ + /** Accepts with a submission or draft; its receipt commits in that write's transaction. */ settlesWithWrite: true settledOutcome?: never } @@ -123,7 +121,10 @@ export function sendPlan(params: { body: params.body }) }, - replay: (ctx, outcome) => { + replay: (ctx, outcome, acceptedSend) => { + if (acceptedSend) { + return acceptedSend + } // A send this host queued answers from its draft, then its hand-off; a // withdrawn draft replays as spent — never as missing-submission doubt. Only a send that // asked to be queued may get that answer: a direct send the host kept as a card answers @@ -194,7 +195,10 @@ export function conversationCommandPlan(params: { }) return sent.ok ? { ok: true, value: { clientMessageId } } : sent }, - replay: (ctx, outcome) => { + replay: (ctx, outcome, acceptedSend) => { + if (acceptedSend) { + return { clientMessageId } + } if (outcome.status === 'succeeded' && outcome.conversationCommand) { return { recorded: outcome.conversationCommand } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 0ed7c85e2b81..17f911ea1d0f 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -1,4 +1,3 @@ -import { join } from 'node:path' import { afterEach, expect, it, vi } from 'vitest' import { AgentSessionPreDispatchError, @@ -6,6 +5,7 @@ import { } from './structured-agent-session-operation-settlement' import { adapter, + attach, envelope, hostTestState, journals @@ -22,6 +22,7 @@ import { codexProviderHandle } from '../../../shared/agent-session-provider-hand import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' async function context(): Promise { + await attach() return { logger: createStructuredAgentSessionLogger(), sessionId: SESSION, @@ -33,7 +34,7 @@ async function context(): Promise { agent: 'codex', providerHandle: codexProviderHandle(THREAD) }, - stateDirectory: join(hostTestState().root, 'settlement') + stateDirectory: hostTestState().root }), fence: 1, agents: NO_STRUCTURED_AGENTS, @@ -79,7 +80,7 @@ it('rethrows a throw from a plan answered by its write, writing nothing and leav }) it.each([ - { plan: 'answered by its write', settlesWithWrite: true as const, failures: 1 }, + { plan: 'answered by its write', settlesWithWrite: true as const, failures: 0 }, { plan: 'settled after its run', settlesWithWrite: undefined, failures: 2 } ])( 'preserves a proven refusal of a plan $plan through $failures failed bookkeeping writes', @@ -165,7 +166,7 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing', expect(vi.getTimerCount()).toBe(0) }) -it('keeps the original refusal reason and message in its settled receipt', async () => { +it('returns the pre-acceptance refusal without recording a receipt', async () => { const ctx = await context() const { store } = hostTestState() const writes = vi.spyOn(store, 'recordOperationOutcome').mockResolvedValue() @@ -188,9 +189,5 @@ it('keeps the original refusal reason and message in its settled receipt', async } }) ).toMatchObject({ ok: false, refusal }) - expect(writes).toHaveBeenCalledWith({ - callerKey: 'test', - operationId: operation.clientOperationId, - outcome: { status: 'failed', ...refusal } - }) + expect(writes).not.toHaveBeenCalled() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index c6f21301ef4c..7dd17f04d5d1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -1,8 +1,8 @@ import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' -import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' +import { runCommandReceiptMutation } from './structured-agent-session-command-receipt' /** Only thrown while the provider dispatch is still unreachable. */ export class AgentSessionPreDispatchError extends Error { @@ -19,6 +19,9 @@ export async function runSettledAgentSessionMutation(input: { plan: MutationPlan context: AgentSessionTurnContext }): Promise> { + if (input.plan.settlesWithWrite) { + return runCommandReceiptMutation(input) + } const operation = { callerKey: input.operationCallerKey, operationId: input.envelope.clientOperationId @@ -26,28 +29,9 @@ export async function runSettledAgentSessionMutation(input: { const settle = ( outcome: Parameters[0]['outcome'] ) => input.store.recordOperationOutcome({ ...operation, outcome }) - const receipt = input.plan.settlesWithWrite - ? observedReceipt( - input.store.operationOutcomeReceipt({ - ...operation, - outcome: { status: 'succeeded', sessionId: input.envelope.sessionId } - }) - ) - : undefined - const context = receipt ? { ...input.context, operationReceipt: receipt } : input.context let outcome: TurnOutcome | undefined try { - const ran = await input.plan.run(context) - if (receipt?.wasCommitted() && !ran.ok) { - // The row says accepted, so a refusal past it (a fold that failed after COMMIT) is a fault. - throw new Error( - `operation ${operation.operationId} was accepted, then refused: ${ran.refusal.code}` - ) - } - if (receipt?.wasCommitted()) { - return ran - } - outcome = ran + outcome = await input.plan.run(input.context) await settle( outcome.ok ? (input.plan.settledOutcome?.(outcome.value) ?? { @@ -66,18 +50,15 @@ export async function runSettledAgentSessionMutation(input: { return outcome } catch (error) { const { logger, sessionId } = input.context - // Its success commits with its write, so a throw leaves the row pending: nothing was written. - if (!input.plan.settlesWithWrite) { - try { - await settle({ status: 'unknown' }) - } catch { - // Bookkeeping must not replace the operation's proof of whether dispatch began. - logger.warn('recording an operation as unknown failed', { - scope: 'operation-unknown-settlement', - sessionId, - operationId: operation.operationId - }) - } + try { + await settle({ status: 'unknown' }) + } catch { + // Bookkeeping must not replace the operation's proof of whether dispatch began. + logger.warn('recording an operation as unknown failed', { + scope: 'operation-unknown-settlement', + sessionId, + operationId: operation.operationId + }) } if (outcome && !outcome.ok) { logger.warn('recording a refused operation failed', { @@ -90,17 +71,3 @@ export async function runSettledAgentSessionMutation(input: { throw error } } - -function observedReceipt( - receipt: JournalOperationReceipt -): JournalOperationReceipt & { wasCommitted: () => boolean } { - let committed = false - return { - write: receipt.write, - committed: () => { - receipt.committed() - committed = true - }, - wasCommitted: () => committed - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index 7ab0fcfc1f9f..cbcda46ee623 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -216,3 +216,42 @@ describe('/clear', () => { }) }) }) + +it('replays /compact per caller while acknowledging the same submission once', async () => { + const id = hostTestOperationId() + const fields = { command: 'compact' as const } + const params = { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', id) + } + const admit = vi + .spyOn(rig.store, 'admitMutationOperation') + .mockRejectedValue(new Error('ledger unavailable')) + expect(await rig.host.conversationCommand(CALLER, params)).toMatchObject({ + ok: true, + replayed: false + }) + expect(await rig.host.conversationCommand(CALLER, params)).toMatchObject({ + ok: true, + replayed: true + }) + await eventually(() => expect(rig.compact).toHaveBeenCalledTimes(1)) + rig.finishCompact() + await rig.host.flushStreamedEvents(params.envelope.sessionId) + expect(rig.store.readCommandReceipt({ kind: 'caller', callerKey: 'second-caller' }, id)).toEqual({ + verdict: 'absent' + }) + expect(await rig.host.conversationCommand({ callerKey: 'second-caller' }, params)).toMatchObject({ + ok: true, + replayed: false + }) + expect(await rig.host.conversationCommand({ callerKey: 'second-caller' }, params)).toMatchObject({ + ok: true, + replayed: true + }) + expect( + rig.store.readCommandReceipt({ kind: 'caller', callerKey: 'second-caller' }, id) + ).toMatchObject({ verdict: 'readable' }) + expect(admit).not.toHaveBeenCalled() + await eventually(() => expect(rig.compact).toHaveBeenCalledTimes(1)) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts index 82e750b908be..da3629805478 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts @@ -5,11 +5,17 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { createQueuedMessageTestRig, + QUEUED_RIG_CALLER as CALLER, eventually, type QueuedMessageTestRig } from './structured-agent-session-queued-message-rig.test-fixture' import { openRigTurnFor } from './structured-agent-session-queued-rig-turn.test-fixture' -import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + hostTestMessage, + hostTestOperationId, + HOST_TEST_SESSION as SESSION +} from './structured-agent-session-host-test-data' let rig: QueuedMessageTestRig @@ -71,3 +77,98 @@ describe('a repeated draft press under a fresh operation id', () => { expect(await rig.queuePause()).toBeNull() }) }) + +describe('a queued Send receipt', () => { + function params(id: string, text: string) { + const body = hostTestMessage(text) + const fields = { body, delivery: 'queue-if-active' as const } + return { ...fields, envelope: rig.envelope(fields, 'agentSession.send', id) } + } + + it('accepts concurrent copies once and replays through the draft and its handoff', async () => { + const working = await rig.workingSend() + const id = hostTestOperationId() + const send = params(id, 'one queued message') + const [first, second] = await Promise.all([ + rig.host.send(CALLER, send), + rig.host.send(CALLER, send) + ]) + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { state: 'waiting' } } + }) + expect(await rig.drafts()).toHaveLength(1) + expect(rig.store.readCommandReceipt({ kind: 'global' }, id)).toMatchObject({ + verdict: 'readable', + receipt: { result: { kind: 'queued-draft', messageId: id } } + }) + await rig.settleAccepted(working, 'finished') + await eventually(async () => expect(await rig.handoff(id)).toBeDefined()) + expect(await rig.host.send(CALLER, send)).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { queuedMessageId: id } } + }) + const submissions = (await rig.host.journalSnapshot(SESSION)).submissions + expect(submissions.filter((entry) => entry.queuedMessageId === id)).toHaveLength(1) + }) + + it('replays a withdrawn draft as spent without sending it', async () => { + await rig.workingSend() + const id = hostTestOperationId() + const send = params(id, 'withdraw me') + await rig.host.send(CALLER, send) + await rig.deleteQueued(id) + expect(await rig.host.send(CALLER, send)).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { messageId: id, state: 'withdrawn' } } + }) + expect(await rig.handoff(id)).toBeUndefined() + expect(rig.dispatch).toHaveBeenCalledTimes(1) + }) + + it('rolls back a draft whose receipt cannot be saved', async () => { + await rig.workingSend() + const id = hostTestOperationId() + const db = openTestJournalHostDatabase(rig.root).db + db.exec(`CREATE TRIGGER fail_draft_receipt BEFORE INSERT ON agent_session_command_receipts + BEGIN SELECT RAISE(ABORT, 'receipt unavailable'); END`) + await expect(rig.host.send(CALLER, params(id, 'must roll back'))).rejects.toThrow( + 'receipt unavailable' + ) + expect(await rig.drafts()).toHaveLength(0) + expect(rig.store.readCommandReceipt({ kind: 'global' }, id)).toEqual({ verdict: 'absent' }) + expect(rig.store.listOperationRows().find((row) => row.operationId === id)).toBeUndefined() + db.exec('DROP TRIGGER fail_draft_receipt') + expect(await rig.host.send(CALLER, params(id, 'must roll back'))).toMatchObject({ + ok: true, + replayed: false + }) + }) +}) + +it('returns a committed queued Send after its publication fails', async () => { + await rig.workingSend() + const id = hostTestOperationId() + const body = hostTestMessage('committed queued draft') + const fields = { body, delivery: 'queue-if-active' as const } + const params = { ...fields, envelope: rig.envelope(fields, 'agentSession.send', id) } + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)!.journal + journal.observeCommits(() => { + throw new Error('draft publication failed') + }) + try { + expect(await rig.host.send(CALLER, params)).toMatchObject({ + ok: true, + replayed: false, + value: { queued: { messageId: id, position: 1, state: 'waiting' } } + }) + expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(await rig.drafts()).toHaveLength(1) + } finally { + journal.observeCommits(() => {}) + } +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts index 1652fbca97f2..d472e159fcbc 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-send.ts @@ -26,9 +26,5 @@ export async function runQueueableStructuredAgentSessionSend( if (queued) { return queued } - const accepted = await immediate() - if (accepted.ok) { - context.wakeDelivery(ctx.sessionId) - } - return accepted + return immediate() } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts index bc40f51c4661..558fadcb229e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-host-test-harness' import { HOST_TEST_SESSION as SESSION, + HOST_TEST_NOW as NOW, HOST_TEST_THREAD as THREAD, hostTestMessage } from './structured-agent-session-host-test-data' @@ -35,9 +36,7 @@ beforeEach(() => { afterEach(() => vi.restoreAllMocks()) function hostJournal(): AgentSessionJournal { - return ( - host as unknown as { sessions: Map } - ).sessions.get(SESSION)!.journal + return host.collaboratorsForTests().sessions.get(SESSION)!.journal } function sendParams(text: string) { @@ -50,7 +49,7 @@ async function deliveredOnce(): Promise { } describe('a resent send id', () => { - it('is answered from a refused row without opening the chat again', async () => { + it('re-evaluates a pre-acceptance failure after the chat is reopened', async () => { await attach() vi.spyOn(hostJournal(), 'appendSubmission').mockRejectedValueOnce(new Error('disk full')) const params = sendParams('refused once') @@ -64,16 +63,9 @@ describe('a resent send id', () => { const resent = await host.send(CALLER, params) - expect(resent).toMatchObject({ - ok: false, - refusal: { - code: 'agent_session_operation_invalid', - details: { reason: 'journalWriteFailed' } - } - }) - // The answer came from the ledger alone: the closed chat was not opened to give it. - expect(host.hasSession(SESSION)).toBe(false) - expect(dispatch).not.toHaveBeenCalled() + expect(resent).toMatchObject({ ok: true, replayed: false }) + expect(host.hasSession(SESSION)).toBe(true) + await deliveredOnce() }) it('answers unknown, never a refusal, when the chat holding its answer cannot be opened', async () => { @@ -242,3 +234,230 @@ describe('a send queued behind a running turn', () => { } }) }) + +describe('send receipt acceptance', () => { + it.each(['body', 'delivery'] as const)( + 'conflicts when the same id changes its %s', + async (field) => { + await attach() + const params = sendParams('original') + expect(await host.send(CALLER, params)).toMatchObject({ ok: true }) + await deliveredOnce() + const body = field === 'body' ? hostTestMessage('changed') : params.body + const fields = { + body, + ...(field === 'delivery' ? { delivery: 'queue-if-active' as const } : {}) + } + await expect( + host.send(CALLER, { + ...fields, + envelope: envelope('agentSession.send', fields, { + clientOperationId: params.envelope.clientOperationId + }) + }) + ).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + } + ) + + it('conflicts with an unswitched caller ledger row even after its expiry', async () => { + await attach() + const body = hostTestMessage('reuses Stop') + const old = NOW - 3 * 24 * 60 * 60_000 + const params = { + envelope: envelope( + 'agentSession.send', + { body }, + { clientOperationId: `${old}-${'a'.repeat(32)}` } + ), + body + } + await store.admitOperation({ + callerKey: 'another-caller', + operationId: params.envelope.clientOperationId, + fingerprint: envelope('agentSession.cancel', {}).payloadFingerprint, + now: old + }) + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ?.expiresAt + ).toBeLessThan(NOW) + const before = store.listOperationRows() + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + } + }) + expect(store.listOperationRows()).toEqual(before) + expect(hostJournal().submissions()).toHaveLength(0) + expect(store.readCommandReceipt({ kind: 'global' }, params.envelope.clientOperationId)).toEqual( + { verdict: 'absent' } + ) + }) + + it('keeps the temporary ledger row so Stop cannot reuse a send id', async () => { + await attach() + const params = sendParams('accepted') + await host.send(CALLER, params) + await deliveredOnce() + await expect( + host.cancel(CALLER, { + envelope: envelope( + 'agentSession.cancel', + {}, + { clientOperationId: params.envelope.clientOperationId } + ) + }) + ).resolves.toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + } + }) + }) + + it.each(['agent_session_command_receipts', 'agent_session_operations'])( + 'rolls back the message when %s cannot be written, then retries the same id fresh', + async (table) => { + await attach() + const params = sendParams('receipt must commit') + const db = openTestJournalHostDatabase(root).db + db.exec(`CREATE TRIGGER fail_receipt BEFORE INSERT ON ${table} + BEGIN SELECT RAISE(ABORT, 'receipt storage failed'); END`) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'journalWriteFailed' } } + }) + expect(hostJournal().submissions()).toHaveLength(0) + expect( + store + .listOperationRows() + .find((row) => row.operationId === params.envelope.clientOperationId) + ).toBeUndefined() + expect( + store.readCommandReceipt({ kind: 'global' }, params.envelope.clientOperationId) + ).toEqual({ verdict: 'absent' }) + expect(dispatch).not.toHaveBeenCalled() + db.exec('DROP TRIGGER fail_receipt') + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + await deliveredOnce() + } + ) + + it('answers unknown for an unreadable receipt and never runs the message again', async () => { + await attach() + const params = sendParams('accepted before corruption') + await host.send(CALLER, params) + await deliveredOnce() + openTestJournalHostDatabase(root) + .db.prepare( + "UPDATE agent_session_command_receipts SET result_json = '{' WHERE operation_id = ?" + ) + .run(params.envelope.clientOperationId) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it('answers an in-transaction duplicate from the receipt after rolling back the second append', async () => { + await attach() + const params = sendParams('accepted once') + await host.send(CALLER, params) + await deliveredOnce() + const journal = hostJournal() + await journal.rollEpoch('schema_unreadable', store.getRecord(SESSION)!.lease.runtimeFence) + const before = journal.cursor() + vi.spyOn(store, 'readCommandReceipt').mockReturnValueOnce({ verdict: 'absent' }) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { clientMessageId: params.envelope.clientOperationId } } + }) + expect(journal.cursor()).toEqual(before) + expect(journal.submissions()).toHaveLength(0) + expect(dispatch).toHaveBeenCalledTimes(1) + }) + + it.each([false, true])( + 'returns the committed send after publication fails, even if reload fails (%s)', + async (reloadFails) => { + await attach() + const params = sendParams('accepted before publication failed') + const journal = hostJournal() + const observe = vi.spyOn(journal, 'refreshCommittedState') + if (reloadFails) { + observe.mockRejectedValueOnce(new Error('reload unavailable')) + } + journal.observeCommits( + vi.fn().mockImplementationOnce(() => { + throw new Error('publication failed') + }) + ) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: false, + value: { submission: { dispatchState: 'pending' } } + }) + expect(observe).toHaveBeenCalledOnce() + expect(journal.submissions()).toHaveLength(1) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) + journal.observeCommits(() => {}) + await deliveredOnce() + } + ) +}) + +it('keeps a same-fingerprint ledger row unchanged while writing the Send receipt', async () => { + await attach() + const params = sendParams('accepted with prior compatibility identity') + await store.admitGlobalOperation({ + callerKey: 'original-caller', + operationId: params.envelope.clientOperationId, + fingerprint: params.envelope.payloadFingerprint, + now: NOW + }) + const prior = store + .listOperationRows() + .find((row) => row.operationId === params.envelope.clientOperationId) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + expect( + store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) + ).toEqual(prior) + expect( + store.readCommandReceipt({ kind: 'global' }, params.envelope.clientOperationId) + ).toMatchObject({ + verdict: 'readable', + receipt: { status: 'accepted', fingerprint: params.envelope.payloadFingerprint } + }) + await deliveredOnce() +}) + +it('preserves global conflicts with a non-chat operation already in the ledger', async () => { + await attach() + const params = sendParams('cannot reuse a terminal launch') + await store.admitOperation({ + callerKey: 'terminal-caller', + operationId: params.envelope.clientOperationId, + fingerprint: 'different-terminal-launch', + now: NOW + }) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict', details: { reason: 'operationIdReused' } } + }) + expect(hostJournal().submissions()).toHaveLength(0) + expect(store.readCommandReceipt({ kind: 'global' }, params.envelope.clientOperationId)).toEqual({ + verdict: 'absent' + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts index e286c8a58ae8..0e91f3d1cf89 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-ledger-refusal.test.ts @@ -1,5 +1,4 @@ -// A send id the ledger refuses is answered with that refusal and nothing else: no chat opened, no -// write. A /clear in flight refuses only a send's first run, judged when the send arrived. +// Send identity outlives the ledger policy; /clear still refuses a first run at its arrival. import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' @@ -20,14 +19,6 @@ import { hostTestMessage } from './structured-agent-session-host-test-data' -const EXPIRED = { - ok: false, - refusal: { - code: 'agent_session_operation_expired', - details: { reason: 'operationExpired' } - } -} - let root: string let store: AgentSessionRecordStore let host: StructuredAgentSessionHost @@ -72,44 +63,68 @@ function clear() { }) } -describe('an expired send id', () => { - it('is answered expired while its chat is closed, not as a chat this host lacks', async () => { +describe('send without ledger policy', () => { + it('accepts an id older than 24 hours while its chat is closed', async () => { await attach() await host.close(SESSION, 'evict') - await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) - expect(host.hasSession(SESSION)).toBe(false) + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject({ + ok: true, + replayed: false + }) + expect(host.hasSession(SESSION)).toBe(true) }) - it('is answered expired by a store a newer Orca wrote', async () => { + it('still refuses a write to a newer database', async () => { await attach() const database = openTestJournalHostDatabase(root) Object.defineProperty(database, 'readOnly', { value: true }) expect(store.readOnly).toBe(true) try { - await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject(EXPIRED) + await expect(host.send(CALLER, expiredParams('long gone'))).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'journalWrittenByNewerOrca' } } + }) } finally { // Teardown stops the live child, which writes. Object.defineProperty(database, 'readOnly', { value: false }) } }) - it('is answered expired when its row lapses while the chat opens for the resend', async () => { + it('accepts a six-minute future id without evaluating or writing admission', async () => { + await attach() + const evaluate = vi.spyOn(store, 'evaluateMutationOperation').mockImplementation(() => { + throw new Error('ledger refuses admission') + }) + const admit = vi + .spyOn(store, 'admitMutationOperation') + .mockRejectedValue(new Error('ledger full')) + const params = sendParams('host clock is behind', `${NOW + 6 * 60_000}-${'f'.repeat(32)}`) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: false }) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) + expect(evaluate).not.toHaveBeenCalled() + expect(admit).not.toHaveBeenCalled() + }) + + it('replays accepted ids after the compatibility ledger has expired', async () => { await attach() - const params = sendParams('recorded, then it lapsed') + const params = sendParams('receipt survives the ledger') await host.send(CALLER, params) await vi.waitFor(() => expect(dispatch).toHaveBeenCalledTimes(1)) - const evaluate = store.evaluateMutationOperation - // The second read, after the open, sees the clock past the row's whole retention. - vi.spyOn(store, 'evaluateMutationOperation') - .mockImplementationOnce(evaluate) - .mockImplementationOnce((args) => - evaluate({ ...args, now: args.now + 3 * AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS }) - ) - - await expect(host.send(CALLER, params)).resolves.toMatchObject(EXPIRED) + const db = openTestJournalHostDatabase(root).db + db.prepare('DELETE FROM agent_session_operations').run() + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) expect(dispatch).toHaveBeenCalledTimes(1) }) + + it.each(['invalid', '1-a', `${NOW}-${'a'.repeat(33)}`])('refuses malformed id %s', async (id) => { + await attach() + await expect(host.send(CALLER, sendParams('invalid', id))).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'operationIdInvalid' } } + }) + expect(dispatch).not.toHaveBeenCalled() + }) }) describe('a /clear in flight', () => { @@ -146,12 +161,15 @@ describe('a /clear in flight', () => { ).toBeLessThanOrEqual(1) }) - it('answers an expired id expired', async () => { + it('refuses an old id only for the clear in flight', async () => { await attach() const clearing = clear() const expired = host.send(CALLER, expiredParams('typed long ago')) await clearing - await expect(expired).resolves.toMatchObject(EXPIRED) + await expect(expired).resolves.toMatchObject({ + ok: false, + refusal: { details: { reason: 'conversationCommandInFlight' } } + }) }) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts index f1bbd673cd62..f9508d49d7a8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send-preparation.test.ts @@ -187,7 +187,7 @@ describe('a send with no live owner', () => { await accept(sendParams('accept first')) await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) - expect(order).toEqual(['admit', 'acquire']) + expect(order).toEqual(['acquire']) }) it('leaves a live owner alone', async () => { @@ -328,16 +328,16 @@ describe('a send with no live owner', () => { expect(acquire).toHaveBeenCalledOnce() }) - it("restarts nothing for an older build's unknown row the journal never saw", async () => { + it('accepts afresh when only an older ledger row exists without a command receipt', async () => { const resent = await admittedThenHostDied(sendParams('marked unknown, then died'), 'unknown') await expect(host.send(CALLER, resent)).resolves.toMatchObject({ ok: true, - replayed: true, - value: { submission: { dispatchState: 'unknown', recovered: true } } + replayed: false, + value: { submission: { dispatchState: 'pending' } } }) - expect(acquire).not.toHaveBeenCalled() - expect(dispatch).not.toHaveBeenCalled() + await eventually(async () => expect(dispatch).toHaveBeenCalledOnce()) + expect(acquire).toHaveBeenCalledOnce() }) it('accepts a send that arrives while a restart holds the queue, and hands both over in order', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts index f382eda54e44..228bf2b66cd6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-send.test.ts @@ -87,7 +87,7 @@ describe('send', () => { expect(page.providerSession).toEqual({ key: 'session_id', id: THREAD }) }) - it('settles a submission write failure as rejected before provider dispatch', async () => { + it('a submission write failure leaves no accepted operation before provider dispatch', async () => { await attach() const journal = ( host as unknown as { sessions: Map } @@ -103,7 +103,7 @@ describe('send', () => { expect(dispatch).not.toHaveBeenCalled() expect( store.listOperationRows().find((row) => row.operationId === params.envelope.clientOperationId) - ).toMatchObject({ outcome: { status: 'failed' } }) + ).toBeUndefined() }) it('settles a thrown dispatch as unknown, never as a rejection', async () => { @@ -338,10 +338,10 @@ describe('send', () => { 'host fault before the write' ) expect(hostJournal().submissions()).toHaveLength(0) - // Its success would have committed with its write, so a row still pending wrote nothing. + // A throw before acceptance leaves no receipt or compatibility ledger row. expect( store.listOperationRows().find((row) => row.operationId === clientMessageId) - ).toMatchObject({ outcome: { status: 'pending' } }) + ).toBeUndefined() await expect(host.send({ callerKey: 'client-after-recovery' }, params)).resolves.toMatchObject({ ok: true, @@ -351,7 +351,7 @@ describe('send', () => { await delivered(clientMessageId) expect( store.listOperationRows().find((row) => row.operationId === clientMessageId) - ).toMatchObject({ callerKey: CALLER.callerKey, outcome: { status: 'succeeded' } }) + ).toMatchObject({ callerKey: 'client-after-recovery', outcome: { status: 'succeeded' } }) await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) expect(dispatch).toHaveBeenCalledTimes(1) }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 200ce5a338ab..29ad96ec562c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -45,6 +45,7 @@ import { } from '../agent-session-journal/journal-open-failure' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' import { isAgentSessionAttachmentExpiredError } from '../agent-session-attachments/agent-session-attachment-claims' +import { CommandReceiptExistsError } from '../agent-session-journal/command-receipt-transaction' import type { StructuredAgentRegistry } from './structured-agent-registry' export { performSetOption } from './structured-agent-session-turns-options' export { performPrompt } from './structured-agent-session-turns-prompt' @@ -159,6 +160,9 @@ export async function performSend( ) } if (existing) { + if (ctx.operationReceipt) { + await ctx.journal.commitSubmissionReceipt(input.clientMessageId, ctx.operationReceipt) + } return { ok: true, value: { clientMessageId: input.clientMessageId, submission: existing } @@ -171,6 +175,9 @@ export async function performSend( ctx.operationReceipt ) } catch (error) { + if (error instanceof CommandReceiptExistsError) { + throw error + } if (isAgentSessionAttachmentExpiredError(error)) { return agentSessionAttachmentExpiredRefusal() } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts index a5db53e5555c..417bec5569bc 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.test.ts @@ -734,7 +734,7 @@ it('delivers the next message after a command whose child died and whose settlem expect(readAgentJournalTurn((await commandTurn(cmid))?.body)?.state).not.toBe('running') }) -it("ignores an older build's unconfirmed compaction record, and answers its operation without rerunning it (B15)", async () => { +it('ignores a ledger-only compaction record with no command receipt (B15)', async () => { await attach() const older = compactParams() // An older build admitted this operation and died before recording its outcome. @@ -775,13 +775,10 @@ it("ignores an older build's unconfirmed compaction record, and answers its oper await expect(state.host.conversationCommand(CALLER, older)).resolves.toMatchObject({ ok: true, - value: { - state: 'unknown', - error: 'Compaction completion is unconfirmed.', - failure: { kind: 'compactionUnconfirmed' } - } + replayed: false, + value: { state: 'completed' } }) - expect(compact).toHaveBeenCalledOnce() + expect(compact).toHaveBeenCalledTimes(2) }) it('never lets a provider echo alias the command entry', async () => { diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index d6d478c01900..c3938be827ba 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -8,6 +8,7 @@ import { findAgentSessionGlobalOperationRow, pruneAgentSessionOperationRows, settleAgentSessionOperation, + pendingAgentSessionOperationRow, type AgentSessionOperationClaim, type AgentSessionOperationDecision, type AgentSessionOperationOutcome, @@ -16,11 +17,13 @@ import { } from '../../shared/agent-session-operation-ledger' import { admitAgentSessionMutation, + agentSessionLedgerRefusal, type AgentSessionMutationAdmission } from '../../shared/agent-session-mutation-envelope' import type { AgentSessionMutationEnvelope } from '../../shared/agent-session-wire' import type { AgentSessionRecord } from '../../shared/agent-session-record' import type { AgentSessionStoreState } from './agent-session-store-state' +import { AgentSessionRefusalError } from '../../shared/agent-session-wire-refusals' export type AgentSessionOperationAdmission = { callerKey: string @@ -29,6 +32,45 @@ export type AgentSessionOperationAdmission = { now: number } +export type AgentSessionOperationAcceptance = AgentSessionOperationAdmission & { + operationIdScope?: 'global' +} + +/** No admission policy: the acceptance transaction preserves only cross-family identity. */ +export function insertAcceptedAgentSessionOperationInto( + state: Pick, + args: AgentSessionOperationAcceptance & { outcome: AgentSessionOperationOutcome } +): void { + const candidates = args.operationIdScope + ? state.operations.values() + : [state.operations.get(agentSessionOperationKey(args.callerKey, args.operationId))] + let exists = false + for (const row of candidates) { + if (!row || row.operationId !== args.operationId) { + continue + } + if (row.fingerprint !== args.fingerprint) { + throw new AgentSessionRefusalError( + agentSessionLedgerRefusal( + { clientOperationId: args.operationId }, + { + decision: 'refused', + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + } + ) + ) + } + exists = true + } + if (!exists) { + state.operations.set(agentSessionOperationKey(args.callerKey, args.operationId), { + ...pendingAgentSessionOperationRow(args), + outcome: args.outcome + }) + } +} + type OperationRows = Map export type AgentSessionMutationOperationAdmission = { diff --git a/src/main/runtime/agent-session-operation-receipt.ts b/src/main/runtime/agent-session-operation-receipt.ts new file mode 100644 index 000000000000..5690634c99a7 --- /dev/null +++ b/src/main/runtime/agent-session-operation-receipt.ts @@ -0,0 +1,25 @@ +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' +import type { AgentSessionOperationOutcome } from '../../shared/agent-session-operation-ledger' +import { + insertAcceptedAgentSessionOperationInto, + settleAgentSessionOperationInto, + type AgentSessionOperationAcceptance +} from './agent-session-operation-admission' +import type { AgentSessionStoreTransactions } from './agent-session-store-transactions' + +export type AgentSessionOperationReceiptInput = + | Parameters[1] + | (AgentSessionOperationAcceptance & { outcome: AgentSessionOperationOutcome }) + +export function agentSessionOperationOutcomeReceipt( + transactions: AgentSessionStoreTransactions, + args: AgentSessionOperationReceiptInput +): JournalOperationReceipt { + if ('fingerprint' in args) { + // TEMPORARY until native-chat writes stop using the ledger: preserve cross-family conflicts. + return transactions.receipt((draft) => insertAcceptedAgentSessionOperationInto(draft, args), { + insertOperationsIfAbsent: true + }) + } + return transactions.receipt((draft) => settleAgentSessionOperationInto(draft, args)) +} diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index ecc9fd9d72c3..ed966d0b34be 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -14,6 +14,7 @@ import type Database from '../sqlite/sync-database' import type { SqliteRow } from '../sqlite/sqlite-statement' import type { AgentSessionStoreState } from './agent-session-store-state' import type { AgentSessionStoreRowWrites } from './agent-session-store-draft' +import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusals' import { isReadableAgentSessionStoreOperation, isReadableAgentSessionStoreRecord, @@ -178,6 +179,23 @@ function writeTabIndex( } } +/** Compatibility receipts may add a ledger row, never replace an existing operation. */ +export function insertAgentSessionOperationRowsIfAbsent( + db: Database.Database, + rows: AgentSessionStoreRowWrites['operations']['upsert'] +): void { + const insert = db.prepare( + 'INSERT INTO agent_session_operations (operation_key, row_json) VALUES (?, ?) ON CONFLICT(operation_key) DO NOTHING' + ) + for (const [key, json] of rows) { + if (insert.run(key, json).changes === 0) { + throw agentSessionRefusalError('agent_session_operation_unknown', { + reason: 'outcomeUnknown' + }) + } + } +} + /** Exactly the rows a transaction changed. The caller holds the journal transaction. */ export function writeAgentSessionStoreRows( db: Database.Database, diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index c78a433415dc..9660852deab1 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -10,7 +10,6 @@ import { import { pinAgentSessionRecordLaunchDirectory } from './agent-session-record-launch-directory' import { agentSessionOperationKey, - type AgentSessionOperationClaim, type AgentSessionOperationDecision, type AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger' @@ -73,7 +72,7 @@ import { showAgentSessionTabs } from './agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' -import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' +import { agentSessionOperationOutcomeReceipt } from './agent-session-operation-receipt' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' import { @@ -82,8 +81,6 @@ import { setAgentSessionRecordConversationName } from './agent-session-record-conversation-name' -type AgentSessionOperationSettlement = Parameters[1] - export const AGENT_SESSION_LEASE_TTL_MS = 30_000, AGENT_SESSION_LEASE_RENEW_INTERVAL_MS = 10_000 @@ -298,10 +295,7 @@ export class AgentSessionRecordStore { /** Durable compare-and-swap for the right to run an admitted operation's effect: two replays both * read `pending`, and only a conditional swap tells the one that may run from the one that must * replay. */ - claimOperation = (args: { - callerKey: string - operationId: string - }): Promise => + claimOperation = (args: Parameters[1]) => this.transact((draft) => claimAgentSessionOperationInto(draft, args)) /** Admission and, when `claimAfter` allows, the claim: one durable write before the effect. */ @@ -310,14 +304,19 @@ export class AgentSessionRecordStore { claimAfter: ClaimAfterAdmission ) => this.transact((draft) => admitAndClaimAgentSessionOperationInto(draft, args, claimAfter)) - async recordOperationOutcome(args: AgentSessionOperationSettlement): Promise { + async recordOperationOutcome( + args: Parameters[1] + ): Promise { await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } /** The same settlement, committed by the journal write that makes it true. It changes only the * ledger, so no record listener is owed. */ - operationOutcomeReceipt = (args: AgentSessionOperationSettlement): JournalOperationReceipt => - this.transactions.receipt((draft) => settleAgentSessionOperationInto(draft, args)) + operationOutcomeReceipt = (args: Parameters[1]) => + agentSessionOperationOutcomeReceipt(this.transactions, args) + + readCommandReceipt = (...args: Parameters) => + this.transactions.readCommandReceipt(...args) replaceSessionOptions = (args: AgentSessionOptionsReplacement): Promise => this.mutate(args.sessionId, (record) => replaceAgentSessionRecordOptions(record, args)) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 3877da33af42..ed20d364f865 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -12,7 +12,12 @@ import type { JournalOperationReceipt } from '../native-chat/agent-session-journ import { journalOpenRefusalError } from '../native-chat/agent-session-journal/journal-open-failure' import { AgentSessionJournalError } from '../native-chat/agent-session-journal/journal-write-guards' import type { AgentSessionStoreState } from './agent-session-store-state' -import { writeAgentSessionStoreRows } from './agent-session-record-rows' +import { + insertAgentSessionOperationRowsIfAbsent, + writeAgentSessionStoreRows +} from './agent-session-record-rows' +import { readCommandReceipt } from '../native-chat/agent-session-journal/command-receipt-table' +import type { CommandReceiptScope } from '../native-chat/agent-session-journal/command-receipt-schema' import { agentSessionRuntimeIncarnation, attributeAgentSessionRuntime @@ -108,6 +113,10 @@ export class AgentSessionStoreTransactions { return this.journalDatabase.readOnly } + readCommandReceipt(scope: CommandReceiptScope, operationId: string) { + return readCommandReceipt(this.journalDatabase.db, scope, operationId) + } + /** * `apply` changes only the draft it is given. On a database a newer Orca wrote every transaction * is refused, except one marked `inMemoryWhenReadOnly`: it is published without a write, for a @@ -127,7 +136,10 @@ export class AgentSessionStoreTransactions { * commits. Exact without the queue: `write` and `committed` run in one synchronous step, so no * store transaction can commit between the draft's staging and its adoption. */ - receipt(apply: (draft: AgentSessionStoreState) => void): JournalOperationReceipt { + receipt( + apply: (draft: AgentSessionStoreState) => void, + options: { insertOperationsIfAbsent?: true } = {} + ): JournalOperationReceipt { let staged: StagedStoreTransaction | null = null return { write: (db) => { @@ -137,7 +149,11 @@ export class AgentSessionStoreTransactions { staged = this.stage(apply) const writes = staged.writes if (writes) { - writeAgentSessionStoreRows(db, writes) + if (options.insertOperationsIfAbsent) { + insertAgentSessionOperationRowsIfAbsent(db, writes.operations.upsert) + } else { + writeAgentSessionStoreRows(db, writes) + } } }, committed: () => { diff --git a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts index 2a8f9a2fc852..a0d4c4674c95 100644 --- a/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts +++ b/tests/e2e/cross-version-wire/versioned-agent-session-wire.ts @@ -136,6 +136,7 @@ type SendAdmissionModules = { plan: unknown envelope: SentMessage['envelope'] journal: () => undefined + store: { getRecord: () => null } }) => Promise } @@ -144,12 +145,13 @@ async function admitSend( admission: Record, sent: SentMessage ): Promise { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the build's own send plan and admission; with no `prepareSession` it reads only plan, envelope and journal. A drifted export or shape fails this check. + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the build's own send plan and admission; the absent session and journal stop both admission routes after fingerprint validation. A drifted export or shape fails this check. const build = { ...plans, ...admission } as unknown as SendAdmissionModules return build.admitAndRunAgentSessionMutation({ plan: build.sendPlan(sent), envelope: sent.envelope, - journal: () => undefined + journal: () => undefined, + store: { getRecord: () => null } }) } From 22526d2c1e3ee9b8d7d5c65cabb2e3f31785deee Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:59:39 -0700 Subject: [PATCH 05/11] fix(native-chat): answer a send's receipt one way, share /compact's id space with Send, wake delivery once - After its commit, a send whose publication fails answers through the same receipt replay a retry takes, so one id gets one answer; the in-memory copy and journal reload are gone. - /compact ids are global like Send's: a retry under a new connection replays instead of needing a second acknowledgment write. - The receipt route calls its runner directly; the runner wakes delivery when a new message row commits. - The compatibility ledger row expires from host time; each temporary site is labelled. - Receipt read errors and post-commit failures are logged. --- .../journal-row-writer.ts | 22 -- .../agent-session-journal/journal-store.ts | 18 +- .../journal-submission-fold.ts | 47 ++-- .../journal-submission-writer.ts | 9 - ...uctured-agent-session-command-admission.ts | 26 +- ...tructured-agent-session-command-receipt.ts | 103 ++++---- ...ctured-agent-session-mutation-admission.ts | 33 +-- ...red-agent-session-mutation-context.test.ts | 239 ++++++++++++++++++ ...ructured-agent-session-mutation-context.ts | 24 +- ...structured-agent-session-mutation-plans.ts | 17 +- ...uctured-agent-session-mutation-refusals.ts | 15 ++ ...agent-session-operation-settlement.test.ts | 32 ++- ...ured-agent-session-operation-settlement.ts | 6 +- ...tured-agent-session-queued-command.test.ts | 95 +++++-- ...-agent-session-queued-repeat-press.test.ts | 2 +- ...ctured-agent-session-resend-answer.test.ts | 58 +++-- .../structured-agent-session-turns.ts | 8 +- .../structured-conversation-compaction.ts | 6 +- .../agent-session-operation-admission.test.ts | 31 ++- .../agent-session-operation-admission.ts | 4 +- .../agent-session-operation-receipt.ts | 2 +- src/main/runtime/agent-session-record-rows.ts | 2 +- .../agent-session-store-transactions.ts | 1 + 23 files changed, 526 insertions(+), 274 deletions(-) create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-mutation-refusals.ts diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 0e3ae51bccd2..8fed5cd0fec9 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -5,7 +5,6 @@ import type { AgentJournalCursor } from '../../../shared/agent-session-journal-t import type { JournalRow } from './journal-row-schema' import { assertJournalFence, assertJournalWritable } from './journal-write-guards' import type { JournalWriteBody } from './journal-write-queue' -import { readJournalRowsAfterCursor } from './journal-open' /** Runs between BEGIN IMMEDIATE and COMMIT, on the SAME connection as the row * insert; a throw rolls the whole append back. Synchronous by construction so @@ -51,27 +50,6 @@ const BOOKKEEPING_SAVEPOINT = 'journal_row_bookkeeping' export class JournalRowWriter { constructor(private readonly deps: JournalRowWriterDeps) {} - /** Another caller may acknowledge an existing submission without accepting it twice. */ - commitReceipt(cursor: AgentJournalCursor, receipt: JournalOperationReceipt): Promise { - return this.deps.serialize(() => { - assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - this.deps.database().transaction((db) => { - const row = readJournalRowsAfterCursor( - db, - this.deps.sessionId, - cursor.epoch, - cursor.sequence - 1, - 1 - )[0] - if (!row || row.seq !== cursor.sequence || row.kind !== 'submission') { - throw new Error('accepted submission is missing') - } - receipt.write(db, row) - }) - receipt.committed() - }) - } - enqueue( build: (seq: number, ts: number) => JournalRow, hook?: JournalRowTransactionHook, diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index 81d1fc45a469..06bdfba25fea 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -28,8 +28,7 @@ import { import type { JournalReplacementItem } from './journal-epoch-replacement' import { readJournalSince } from './journal-cursor' import type { JournalHostDatabase } from './journal-host-database' -import { journalRowsAfterReader, replayJournal, type JournalLoad } from './journal-open' -import { failLoadOnUnloadableJournal } from './journal-open-failure' +import { journalRowsAfterReader, type JournalLoad } from './journal-open' import { markJournalPendingSubmissionsUnknown, rejectJournalPendingSubmissions, @@ -261,18 +260,6 @@ export class AgentSessionJournal { return this.queue.readInOrder(read) } - /** Re-derive the fold after a committed append failed to publish; never writes or notifies. */ - refreshCommittedState(): Promise { - return this.readInOrder(() => { - const loaded = replayJournal(this.database.db, this.identity.sessionId) - if (!loaded) { - throw new Error('committed journal is missing') - } - failLoadOnUnloadableJournal(this.identity.sessionId, loaded) - this.adoptLoadedJournal(loaded) - }) - } - readSince(cursor: AgentJournalCursor, limit?: number): JournalReadSince { const { sessionId } = this.identity const rowsAfter = journalRowsAfterReader(this.database.db, sessionId, this.state.epoch, limit) @@ -346,9 +333,6 @@ export class AgentSessionJournal { return this.submissionWriter.append(input, consume, receipt) } - commitSubmissionReceipt: JournalSubmissionWriter['commitReceipt'] = (id, receipt) => - this.submissionWriter.commitReceipt(id, receipt) - /** A dispatch transition (`JournalSubmissionWriter.resolveDispatch`). */ resolveDispatch( input: ResolveDispatchInput, diff --git a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts index 6ae93f903e45..7b03b4f457f8 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-fold.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-fold.ts @@ -16,7 +16,26 @@ export function applyJournalSubmission( state: JournalReducerState, row: Extract ): void { - state.submissions.set(row.clientMessageId, journalSubmissionFromRow(row)) + state.submissions.set(row.clientMessageId, { + clientMessageId: row.clientMessageId, + fence: row.fence, + payloadFingerprint: row.payloadFingerprint, + dispatchState: 'pending', + providerItemId: null, + reason: null, + submittedAt: row.ts, + resolvedAt: null, + submittedSequence: row.seq, + ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), + // A malformed stored link is dropped, never the row. + ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 + ? { queuedMessageId: row.queuedMessageId } + : {}), + ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}), + // Kept as written, a newer build's kind too; an undecodable one as an empty kind, so neither + // reads as a row without one. + ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}) + }) const itemId = agentJournalSubmissionKey(row.clientMessageId) // A message handed over later belongs to no turn until its handover names one. const turnScope = row.handoverRecorded @@ -128,29 +147,3 @@ function storedSourceKind(stored: unknown): string { ? stored.kind : '' } - -/** The acceptance projection is shared by the fold and a reply after failed publication. */ -export function journalSubmissionFromRow( - row: Extract -): AgentJournalSubmission { - return { - clientMessageId: row.clientMessageId, - fence: row.fence, - payloadFingerprint: row.payloadFingerprint, - dispatchState: 'pending', - providerItemId: null, - reason: null, - submittedAt: row.ts, - resolvedAt: null, - submittedSequence: row.seq, - ...(row.handoverRecorded ? { handoverRecorded: true, acceptedSequence: row.seq } : {}), - // A malformed stored link is dropped, never the row. - ...(typeof row.queuedMessageId === 'string' && row.queuedMessageId.length > 0 - ? { queuedMessageId: row.queuedMessageId } - : {}), - ...(row.origin === 'client' || row.origin === 'host' ? { origin: row.origin } : {}), - // Kept as written, a newer build's kind too; an undecodable one as an empty kind, so neither - // reads as a row without one. - ...(row.source !== undefined ? { source: { kind: storedSourceKind(row.source) } } : {}) - } -} diff --git a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts index d5ff454b817e..1fc0ac21d0da 100644 --- a/src/main/native-chat/agent-session-journal/journal-submission-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-submission-writer.ts @@ -31,15 +31,6 @@ export type JournalSubmissionWriterDeps = { export class JournalSubmissionWriter { constructor(private readonly deps: JournalSubmissionWriterDeps) {} - commitReceipt(clientMessageId: string, receipt: JournalOperationReceipt): Promise { - const state = this.deps.state() - const sequence = state.submissions.get(clientMessageId)?.submittedSequence - if (sequence === undefined) { - throw new Error('accepted submission has no journal position') - } - return this.deps.rowWriter.commitReceipt({ epoch: state.epoch, sequence }, receipt) - } - /** * Write-ahead submission row. It is durable before the caller dispatches * anything, and it doubles as the optimistic user bubble so an accepted echo diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts index 3f114f64b4e9..2de6745fe0d2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts @@ -12,11 +12,11 @@ import type { ExistingCommandReceipt } from '../agent-session-journal/command-re import { CommandReceiptExistsError } from '../agent-session-journal/command-receipt-transaction' import { AGENT_SESSION_NOT_ATTACHED, - refuseAgentSessionMutation, - type AgentSessionMutationRequest -} from './structured-agent-session-mutation-admission' + refuseAgentSessionMutation +} from './structured-agent-session-mutation-refusals' +import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' -import { runSettledAgentSessionMutation } from './structured-agent-session-operation-settlement' +import { runCommandReceiptMutation } from './structured-agent-session-command-receipt' import { agentSessionOperationOutcomeUnknown, resolveAgentSessionReplayOutcome @@ -47,7 +47,13 @@ export async function admitCommandReceiptMutation( commandReceiptScope(callerKey, plan.operationIdScope), envelope.clientOperationId ) - } catch { + } catch (error) { + request.logger.warn('reading a command receipt failed', { + scope: 'command-receipt-read', + sessionId: envelope.sessionId, + operationId: envelope.clientOperationId, + error + }) return unknown(request) } if (read.verdict !== 'absent') { @@ -65,13 +71,21 @@ export async function admitCommandReceiptMutation( // These plans write only to the conversation, whose journal enforces the execution-host fence. const context = mutationTurnContext(request, journal, current) try { - const outcome = await runSettledAgentSessionMutation({ + const outcome = await runCommandReceiptMutation({ store, operationCallerKey: callerKey, + fingerprint: hostFingerprint, + wakeDelivery: request.wakeDelivery, envelope, plan, context }) + if ('committedReceipt' in outcome) { + return answerCommandReceipt(request, hostFingerprint, { + verdict: 'readable', + receipt: outcome.committedReceipt + }) + } return outcome.ok ? { ok: true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts index e5d03867dde6..763506a2c319 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts @@ -1,56 +1,36 @@ -import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' -import type { - AgentSessionSendResult, - AgentSessionMutationEnvelope -} from '../../../shared/agent-session-wire' +import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { commandReceiptScope } from '../agent-session-journal/command-receipt-schema' +import { + commandReceiptScope, + type CommandReceipt +} from '../agent-session-journal/command-receipt-schema' import { buildCommandReceiptTransaction } from '../agent-session-journal/command-receipt-transaction' -import { journalSubmissionFromRow } from '../agent-session-journal/journal-submission-fold' import { composeJournalOperationReceipts } from '../agent-session-journal/journal-row-writer' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' /** Acceptance identity and compatibility bookkeeping share the submission or draft's commit. */ export async function runCommandReceiptMutation(input: { store: AgentSessionRecordStore operationCallerKey: string + fingerprint: string envelope: AgentSessionMutationEnvelope plan: MutationPlan context: AgentSessionTurnContext -}): Promise> { - const { store, operationCallerKey, envelope, plan, context } = input - const fingerprint = computeAgentSessionPayloadFingerprint({ - method: plan.method, - sessionId: envelope.sessionId, - fields: plan.fields - }) - const outcome = { status: 'succeeded' as const, sessionId: envelope.sessionId } - let acceptedSend: AgentSessionSendResult | undefined + wakeDelivery?: (sessionId: string) => void +}): Promise | { committedReceipt: CommandReceipt }> { + const { store, operationCallerKey, fingerprint, envelope, plan, context, wakeDelivery } = input + let acceptedReceipt: CommandReceipt | undefined + let submissionWritten = false const receipt = composeJournalOperationReceipts( buildCommandReceiptTransaction( commandReceiptScope(operationCallerKey, plan.operationIdScope), (row) => { - if (row) { - if (row.kind !== 'submission') { - throw new Error('Send acceptance requires a submission') - } - acceptedSend = { - clientMessageId: envelope.clientOperationId, - submission: journalSubmissionFromRow(row) - } - } else { - const draft = context.journal.queuedMessages.get(envelope.clientOperationId) - if (!draft) { - throw new Error('queued Send acceptance requires a draft') - } - acceptedSend = { - clientMessageId: envelope.clientOperationId, - queued: { messageId: draft.messageId, position: draft.position, state: draft.state } - } + if (row && row.kind !== 'submission') { + throw new Error('Send acceptance requires a submission') } - return { + submissionWritten = row !== undefined + acceptedReceipt = { operationId: envelope.clientOperationId, sessionId: envelope.sessionId, callerKey: operationCallerKey, @@ -62,15 +42,17 @@ export async function runCommandReceiptMutation(input: { ? { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } : { kind: 'queued-draft', messageId: envelope.clientOperationId } } + return acceptedReceipt } ), + // TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. store.operationOutcomeReceipt({ callerKey: operationCallerKey, operationId: envelope.clientOperationId, fingerprint, now: context.now(), ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), - outcome + outcome: { status: 'succeeded', sessionId: envelope.sessionId } }) ) let committed = false @@ -88,34 +70,37 @@ export async function runCommandReceiptMutation(input: { if (!committed || ran.ok) { return ran } + context.logger.warn('publishing an accepted command failed; replaying its receipt', { + scope: 'command-receipt-publication', + sessionId: envelope.sessionId, + operationId: envelope.clientOperationId, + refusal: ran.refusal.code + }) } catch (error) { if (!committed) { throw error } - } - context.logger.warn('publishing an accepted command failed; answering its committed result', { - scope: 'command-receipt-publication', - sessionId: envelope.sessionId, - operationId: envelope.clientOperationId - }) - await context.journal.refreshCommittedState().catch((error: unknown) => { - context.logger.warn('reloading an accepted command failed', { - scope: 'command-receipt-reload', + context.logger.warn('publishing an accepted command failed; replaying its receipt', { + scope: 'command-receipt-publication', sessionId: envelope.sessionId, + operationId: envelope.clientOperationId, error }) - }) - const replay = resolveAgentSessionReplayOutcome({ - operationId: envelope.clientOperationId, - outcome, - reconstruct: () => plan.replay(context, outcome, acceptedSend), - recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState - }) - return replay.decision === 'replay' - ? { ok: true, value: replay.value } - : { ok: false, refusal: replay.decision === 'refuse' ? replay.refusal : unreachableReplay() } -} - -function unreachableReplay(): never { - throw new Error('an accepted command cannot run again') + } finally { + if (committed && submissionWritten) { + try { + wakeDelivery?.(envelope.sessionId) + } catch (error) { + context.logger.warn('waking delivery after acceptance failed', { + scope: 'command-receipt-delivery', + sessionId: envelope.sessionId, + error + }) + } + } + } + if (!acceptedReceipt) { + throw new Error('an accepted command requires its committed receipt') + } + return { committedReceipt: acceptedReceipt } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index d6d2484aac4f..a9c7a68115b1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -22,14 +22,12 @@ import type { AgentSessionOperationRow } from '../../../shared/agent-session-operation-ledger' import type { AgentSessionRecord } from '../../../shared/agent-session-record' -import { - refuse, - type AgentSessionMutationEnvelope, - type AgentSessionMutationResult, - type AgentSessionWireRefusal +import type { + AgentSessionMutationEnvelope, + AgentSessionMutationResult, + AgentSessionWireRefusal } from '../../../shared/agent-session-wire' import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' -import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' import type { AgentSessionMutationOperationAdmission, AgentSessionMutationOperationDecision @@ -53,20 +51,14 @@ import type { StructuredAgentSessionLogger } from './structured-agent-session-lo import type { StructuredAgentRegistry } from './structured-agent-registry' import { admitCommandReceiptMutation } from './structured-agent-session-command-admission' -// The code is shared with the client so a read that refuses this way can be told apart from a -// transcript that failed to load; the two must never drift apart. -export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = refuse( - AGENT_SESSION_UNATTACHED_REFUSAL_CODE, - { reason: 'sessionNotAttached' }, - 'This host holds no attached session by that id.' -) - -export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { - ok: false - refusal: AgentSessionWireRefusal -} { - return { ok: false, refusal } -} +import { + AGENT_SESSION_NOT_ATTACHED, + refuseAgentSessionMutation +} from './structured-agent-session-mutation-refusals' +export { + AGENT_SESSION_NOT_ATTACHED, + refuseAgentSessionMutation +} from './structured-agent-session-mutation-refusals' export type AgentSessionMutationSessionPreparation = | { ok: true } @@ -89,6 +81,7 @@ export type AgentSessionMutationRequest = { record: AgentSessionRecord ) => Promise publish: (journal: AgentSessionJournal) => void + wakeDelivery?: (sessionId: string) => void providerChildPhase?: AgentSessionTurnContext['providerChildPhase'] now: () => number } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts new file mode 100644 index 000000000000..99120dcc23b5 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import * as reducer from '../agent-session-journal/journal-reducer' +import { DISPATCH_DOUBT_SUBMISSION_MISSING } from '../agent-session-journal/journal-dispatch-doubt-reasons' +import { sendStructuredAgentSessionTurn } from './structured-agent-session-host-mutations' +import { mutateStructuredAgentSession } from './structured-agent-session-mutation-context' +import { sendPlan } from './structured-agent-session-mutation-plans' +import { runStructuredCompaction } from './structured-conversation-compaction' +import { + createQueuedMessageTestRig, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_SESSION as SESSION, + hostTestMessage, + hostTestOperationId +} from './structured-agent-session-host-test-data' + +let rig: QueuedMessageTestRig +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) +afterEach(async () => { + vi.restoreAllMocks() + await rig.dispose() +}) + +function params(delivery?: 'queue-if-active') { + const body = hostTestMessage('accepted once') + const fields = { body, ...(delivery ? { delivery } : {}) } + return { ...fields, envelope: rig.envelope(fields, 'agentSession.send', hostTestOperationId()) } +} + +function context() { + return { ...rig.host['mutationContext'](), wakeDelivery: vi.fn() } +} + +it('wakes once for a direct send and never for its receipt replay', async () => { + const ctx = context() + const request = params() + expect(await sendStructuredAgentSessionTurn(ctx, CALLER, request)).toMatchObject({ + ok: true, + replayed: false + }) + expect(ctx.wakeDelivery).toHaveBeenCalledExactlyOnceWith(SESSION) + expect(await sendStructuredAgentSessionTurn(ctx, CALLER, request)).toMatchObject({ + ok: true, + replayed: true + }) + expect(ctx.wakeDelivery).toHaveBeenCalledOnce() + expect(rig.host['sessions'].get(SESSION)?.journal.submissions()).toHaveLength(1) +}) + +it('does not wake for a queued draft or its replay', async () => { + await rig.workingSend() + const ctx = context() + const request = params('queue-if-active') + const accepted = await sendStructuredAgentSessionTurn(ctx, CALLER, request) + expect(accepted).toMatchObject({ + ok: true, + replayed: false, + value: { queued: { state: 'waiting' } } + }) + const replay = await sendStructuredAgentSessionTurn(ctx, CALLER, request) + expect(replay).toMatchObject({ ok: true, replayed: true }) + if (!accepted.ok || !replay.ok) { + throw new Error('expected accepted drafts') + } + expect(replay.value).toEqual(accepted.value) + expect(ctx.wakeDelivery).not.toHaveBeenCalled() +}) + +it('wakes once after a direct send commits even when publication fails', async () => { + const ctx = context() + const request = params() + const error = new Error('listener failed after commit') + rig.host['sessions'].get(SESSION)?.journal.observeCommits( + vi.fn().mockImplementationOnce(() => { + throw error + }) + ) + const warn = vi.spyOn(ctx.deps.logger, 'warn') + expect(await sendStructuredAgentSessionTurn(ctx, CALLER, request)).toMatchObject({ + ok: true, + replayed: true + }) + expect(ctx.wakeDelivery).toHaveBeenCalledExactlyOnceWith(SESSION) + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ scope: 'send-journal-write', error }) + ) + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + scope: 'command-receipt-publication', + refusal: 'agent_session_operation_invalid' + }) + ) +}) + +it('replays a queued draft after publication fails without waking delivery', async () => { + await rig.workingSend() + const ctx = context() + const request = params('queue-if-active') + rig.host['sessions'].get(SESSION)?.journal.observeCommits( + vi.fn().mockImplementationOnce(() => { + throw new Error('draft publication failed') + }) + ) + const accepted = await sendStructuredAgentSessionTurn(ctx, CALLER, request) + const replay = await sendStructuredAgentSessionTurn(ctx, CALLER, request) + expect(accepted).toMatchObject({ + ok: true, + replayed: true, + value: { queued: { state: 'waiting' } } + }) + if (!accepted.ok || !replay.ok) { + throw new Error('expected accepted drafts') + } + expect(replay.value).toEqual(accepted.value) + expect(ctx.wakeDelivery).not.toHaveBeenCalled() +}) + +it('answers the same doubt on the first reply and resend when the committed row cannot fold', async () => { + const ctx = context() + const request = params() + const fold = reducer.applyJournalRow + vi.spyOn(reducer, 'applyJournalRow').mockImplementation((state, row) => { + if (row.kind === 'submission') { + throw new Error('submission fold failed') + } + fold(state, row) + }) + const journal = rig.host['sessions'].get(SESSION)!.journal + const append = vi.spyOn(journal, 'appendSubmission') + const accepted = await sendStructuredAgentSessionTurn(ctx, CALLER, request) + const replay = await sendStructuredAgentSessionTurn(ctx, CALLER, request) + expect(accepted).toMatchObject({ + ok: true, + replayed: true, + value: { + submission: { + dispatchState: 'unknown', + reason: DISPATCH_DOUBT_SUBMISSION_MISSING, + recovered: true + } + } + }) + expect(replay).toEqual(accepted) + expect(journal.submissions()).toHaveLength(0) + expect( + rig.store.readCommandReceipt({ kind: 'global' }, request.envelope.clientOperationId) + ).toMatchObject({ verdict: 'readable', receipt: { status: 'accepted' } }) + expect(append).toHaveBeenCalledOnce() + expect(ctx.wakeDelivery).toHaveBeenCalledOnce() +}) + +it('logs a throw after commit and wakes once before answering from receipt replay', async () => { + const ctx = context() + const request = params() + const plan = sendPlan(request) + const error = new Error('plan publication failed') + const warn = vi.spyOn(ctx.deps.logger, 'warn') + const accepted = await mutateStructuredAgentSession(ctx, CALLER, request.envelope, { + ...plan, + run: async (turn) => { + await plan.run(turn) + throw error + } + }) + expect(accepted).toMatchObject({ + ok: true, + replayed: true, + value: { submission: { dispatchState: 'pending' } } + }) + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ scope: 'command-receipt-publication', error }) + ) + expect(ctx.wakeDelivery).toHaveBeenCalledOnce() +}) + +it('logs a wake error and still answers accepted', async () => { + const ctx = context() + const error = new Error('wake failed') + ctx.wakeDelivery.mockImplementation(() => { + throw error + }) + const warn = vi.spyOn(ctx.deps.logger, 'warn') + expect(await sendStructuredAgentSessionTurn(ctx, CALLER, params())).toMatchObject({ ok: true }) + expect(ctx.wakeDelivery).toHaveBeenCalledOnce() + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ scope: 'command-receipt-delivery', error }) + ) +}) + +it('logs a receipt read error and refuses unknown without accepting or waking', async () => { + const ctx = context() + const request = params() + const error = new Error('receipt read failed') + const warn = vi.spyOn(ctx.deps.logger, 'warn') + vi.spyOn(rig.store, 'readCommandReceipt').mockImplementationOnce(() => { + throw error + }) + expect(await sendStructuredAgentSessionTurn(ctx, CALLER, request)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'outcomeUnknown' } } + }) + expect(warn).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + scope: 'command-receipt-read', + operationId: request.envelope.clientOperationId, + error + }) + ) + expect(rig.host['sessions'].get(SESSION)?.journal.submissions()).toHaveLength(0) + expect(ctx.wakeDelivery).not.toHaveBeenCalled() +}) + +it('wakes once for /compact and never for its replay', async () => { + const ctx = context() + const fields = { command: 'compact' as const } + const request = { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', hostTestOperationId()) + } + const host = { waitForSendSettlement: vi.fn(async () => undefined) } + expect(await runStructuredCompaction(ctx, host, CALLER, request)).toMatchObject({ + ok: true, + replayed: false + }) + expect(ctx.wakeDelivery).toHaveBeenCalledExactlyOnceWith(SESSION) + expect( + await runStructuredCompaction(ctx, host, { callerKey: 'second-caller' }, request) + ).toMatchObject({ ok: true, replayed: true }) + expect(ctx.wakeDelivery).toHaveBeenCalledOnce() +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts index 15db20dfae22..66c9b8c3d8a2 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-context.ts @@ -59,8 +59,8 @@ export function mutateStructuredAgentSession( plan: MutationPlan, prepareSession?: AgentSessionMutationRequest['prepareSession'] ): Promise> { - return context.serialize(envelope.sessionId, async () => { - const result = await admitAndRunAgentSessionMutation({ + return context.serialize(envelope.sessionId, () => + admitAndRunAgentSessionMutation({ store: context.deps.store, adapter: context.deps.adapter, agents: context.deps.agents, @@ -71,25 +71,9 @@ export function mutateStructuredAgentSession( journal: () => context.sessions.get(envelope.sessionId)?.journal, prepareSession, publish: (journal) => context.publish(envelope.sessionId, journal), + wakeDelivery: (sessionId) => context.wakeDelivery(sessionId), providerChildPhase: () => context.sessions.get(envelope.sessionId)?.child?.phase, now: () => context.now() }) - if ( - result.ok && - !result.replayed && - plan.settlesWithWrite && - !(typeof result.value === 'object' && result.value !== null && 'queued' in result.value) - ) { - try { - context.wakeDelivery(envelope.sessionId) - } catch (error) { - context.deps.logger.warn('waking delivery after acceptance failed', { - scope: 'command-receipt-delivery', - sessionId: envelope.sessionId, - error - }) - } - } - return result - }) + ) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index e2af5846b778..30d176c5e9ac 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -48,11 +48,7 @@ export type MutationPlan = { /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ runsWithoutLedgerRow?: true run: (ctx: AgentSessionTurnContext) => Promise> - replay: ( - ctx: AgentSessionTurnContext, - outcome: AgentSessionOperationOutcome, - acceptedSend?: AgentSessionSendResult - ) => TValue | null + replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean recoverUnknownFromDurableState?: boolean } & ( @@ -121,10 +117,7 @@ export function sendPlan(params: { body: params.body }) }, - replay: (ctx, outcome, acceptedSend) => { - if (acceptedSend) { - return acceptedSend - } + replay: (ctx, outcome) => { // A send this host queued answers from its draft, then its hand-off; a // withdrawn draft replays as spent — never as missing-submission doubt. Only a send that // asked to be queued may get that answer: a direct send the host kept as a card answers @@ -180,6 +173,7 @@ export function conversationCommandPlan(params: { const clientMessageId = params.envelope.clientOperationId return { method: 'agentSession.conversationCommand', + operationIdScope: 'global', conversationWrite: true, settlesWithWrite: true, fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND }, @@ -195,10 +189,7 @@ export function conversationCommandPlan(params: { }) return sent.ok ? { ok: true, value: { clientMessageId } } : sent }, - replay: (ctx, outcome, acceptedSend) => { - if (acceptedSend) { - return { clientMessageId } - } + replay: (ctx, outcome) => { if (outcome.status === 'succeeded' && outcome.conversationCommand) { return { recorded: outcome.conversationCommand } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-refusals.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-refusals.ts new file mode 100644 index 000000000000..f59f15712253 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-refusals.ts @@ -0,0 +1,15 @@ +import { refuse, type AgentSessionWireRefusal } from '../../../shared/agent-session-wire' +import { AGENT_SESSION_UNATTACHED_REFUSAL_CODE } from '../../../shared/structured-agent-session-read-refusal' + +export const AGENT_SESSION_NOT_ATTACHED: AgentSessionWireRefusal = refuse( + AGENT_SESSION_UNATTACHED_REFUSAL_CODE, + { reason: 'sessionNotAttached' }, + 'This host holds no attached session by that id.' +) + +export function refuseAgentSessionMutation(refusal: AgentSessionWireRefusal): { + ok: false + refusal: AgentSessionWireRefusal +} { + return { ok: false, refusal } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index 17f911ea1d0f..ce8e68798fbf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -17,6 +17,7 @@ import { } from './structured-agent-session-host-test-data' import type { AgentSessionTurnContext } from './structured-agent-session-turns' import { sendPlan } from './structured-agent-session-mutation-plans' +import { runCommandReceiptMutation } from './structured-agent-session-command-receipt' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' @@ -58,9 +59,10 @@ it('rethrows a throw from a plan answered by its write, writing nothing and leav const writes = vi.spyOn(store, 'recordOperationOutcome') const refusal = new AgentSessionPreDispatchError('agent_session_restart_work_superseded') vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const result = await runSettledAgentSessionMutation({ + const result = await runCommandReceiptMutation({ store, operationCallerKey: 'test', + fingerprint: 'test-fingerprint', envelope: envelope('agentSession.send', {}), context: ctx, plan: { @@ -96,19 +98,20 @@ it.each([ refusal: { code: 'agent_session_operation_invalid' as const, message: 'Not sent.' } } const run = vi.fn(async () => refusal) - const result = await runSettledAgentSessionMutation({ + const input = { store, operationCallerKey: 'test', envelope: envelope('agentSession.send', {}), context: ctx, - plan: { - method: 'agentSession.send', - fields: {}, - ...(settlesWithWrite ? { settlesWithWrite } : {}), - run, - replay: () => null - } - }) + plan: { method: 'agentSession.send', fields: {}, run, replay: () => null } + } + const result = settlesWithWrite + ? await runCommandReceiptMutation({ + ...input, + fingerprint: 'test-fingerprint', + plan: { ...input.plan, settlesWithWrite } + }) + : await runSettledAgentSessionMutation(input) expect(result).toEqual(refusal) expect(writes).toHaveBeenCalledTimes(failures) expect(run).toHaveBeenCalledOnce() @@ -126,9 +129,10 @@ it('accepts without touching the provider', async () => { const beforeRun = vi.fn() const body = hostTestMessage('Continue the interrupted work') const operation = envelope('agentSession.send', { body }) - const result = await runSettledAgentSessionMutation({ + const result = await runCommandReceiptMutation({ store, operationCallerKey: 'test', + fingerprint: 'test-fingerprint', envelope: operation, context: ctx, plan: sendPlan({ envelope: operation, body, beforeRun }) @@ -152,9 +156,10 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing', const body = hostTestMessage('Continue the interrupted work') const operation = envelope('agentSession.send', { body }) vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) - const result = await runSettledAgentSessionMutation({ + const result = await runCommandReceiptMutation({ store, operationCallerKey: 'test', + fingerprint: 'test-fingerprint', envelope: operation, context: ctx, plan: sendPlan({ envelope: operation, body, beforeRun }) @@ -178,9 +183,10 @@ it('returns the pre-acceptance refusal without recording a receipt', async () => message: 'The message could not be saved.' } expect( - await runSettledAgentSessionMutation({ + await runCommandReceiptMutation({ store, operationCallerKey: 'test', + fingerprint: 'test-fingerprint', envelope: operation, context: ctx, plan: { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index 7dd17f04d5d1..5b424703d001 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -2,7 +2,6 @@ import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import type { MutationPlan } from './structured-agent-session-mutation-plans' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -import { runCommandReceiptMutation } from './structured-agent-session-command-receipt' /** Only thrown while the provider dispatch is still unreachable. */ export class AgentSessionPreDispatchError extends Error { @@ -16,12 +15,9 @@ export async function runSettledAgentSessionMutation(input: { store: AgentSessionRecordStore operationCallerKey: string envelope: AgentSessionMutationEnvelope - plan: MutationPlan + plan: Extract, { settlesWithWrite?: never }> context: AgentSessionTurnContext }): Promise> { - if (input.plan.settlesWithWrite) { - return runCommandReceiptMutation(input) - } const operation = { callerKey: input.operationCallerKey, operationId: input.envelope.clientOperationId diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts index cbcda46ee623..3c2870b8c784 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-command.test.ts @@ -15,7 +15,9 @@ import { } from './structured-agent-session-queued-message-rig.test-fixture' import { HOST_TEST_THREAD as THREAD, + HOST_TEST_SESSION as SESSION, hostTestMessage, + hostTestAttachParams, hostTestOperationId } from './structured-agent-session-host-test-data' @@ -217,7 +219,7 @@ describe('/clear', () => { }) }) -it('replays /compact per caller while acknowledging the same submission once', async () => { +it('replays /compact across callers with one submission and one provider command', async () => { const id = hostTestOperationId() const fields = { command: 'compact' as const } const params = { @@ -227,31 +229,78 @@ it('replays /compact per caller while acknowledging the same submission once', a const admit = vi .spyOn(rig.store, 'admitMutationOperation') .mockRejectedValue(new Error('ledger unavailable')) - expect(await rig.host.conversationCommand(CALLER, params)).toMatchObject({ - ok: true, - replayed: false - }) - expect(await rig.host.conversationCommand(CALLER, params)).toMatchObject({ - ok: true, - replayed: true - }) - await eventually(() => expect(rig.compact).toHaveBeenCalledTimes(1)) - rig.finishCompact() - await rig.host.flushStreamedEvents(params.envelope.sessionId) + const original = await rig.host.conversationCommand(CALLER, params) + expect(original).toMatchObject({ ok: true, replayed: false }) + await eventually(() => expect(rig.compact).toHaveBeenCalledOnce()) + const replay = await rig.host.conversationCommand({ callerKey: 'second-caller' }, params) + expect(replay).toMatchObject({ ok: true, replayed: true }) + if (!original.ok || !replay.ok) { + throw new Error('expected accepted commands') + } + expect(replay.value).toEqual(original.value) + expect((await rig.host.journalSnapshot(params.envelope.sessionId)).submissions).toHaveLength(1) expect(rig.store.readCommandReceipt({ kind: 'caller', callerKey: 'second-caller' }, id)).toEqual({ verdict: 'absent' }) - expect(await rig.host.conversationCommand({ callerKey: 'second-caller' }, params)).toMatchObject({ - ok: true, - replayed: false + expect(rig.store.readCommandReceipt({ kind: 'global' }, id)).toMatchObject({ + verdict: 'readable', + receipt: { callerKey: CALLER.callerKey } }) - expect(await rig.host.conversationCommand({ callerKey: 'second-caller' }, params)).toMatchObject({ - ok: true, - replayed: true - }) - expect( - rig.store.readCommandReceipt({ kind: 'caller', callerKey: 'second-caller' }, id) - ).toMatchObject({ verdict: 'readable' }) expect(admit).not.toHaveBeenCalled() - await eventually(() => expect(rig.compact).toHaveBeenCalledTimes(1)) + expect(rig.compact).toHaveBeenCalledOnce() +}) + +it('refuses another caller reusing a send id for /compact as a receipt conflict', async () => { + const { id, result } = rig.send('a different request') + await result + const fields = { command: 'compact' as const } + await expect( + rig.host.conversationCommand( + { callerKey: 'second-caller' }, + { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', id) + } + ) + ).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict', details: { reason: 'operationIdReused' } } + }) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect(rig.compact).not.toHaveBeenCalled() +}) + +it("refuses /compact reusing another caller's id for a different conversation", async () => { + const id = hostTestOperationId() + const fields = { command: 'compact' as const } + await expect( + rig.host.conversationCommand(CALLER, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', id) + }) + ).resolves.toMatchObject({ ok: true }) + const otherSessionId = 'session-beta' + const caller = { callerKey: 'second-caller' } + const attach = hostTestAttachParams(null) + await expect( + rig.host.attach( + caller, + hostTestAttachParams(null, { + envelope: { ...attach.envelope, sessionId: otherSessionId }, + providerHandle: { kind: 'codex', threadId: '019fd532-7c11-7a90-b6de-4e1a2c3d5f61' } + }) + ) + ).resolves.toMatchObject({ ok: true }) + await expect( + rig.host.conversationCommand(caller, { + ...fields, + envelope: rig.envelope(fields, 'agentSession.conversationCommand', id, otherSessionId) + }) + ).resolves.toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_conflict', details: { reason: 'operationIdReused' } } + }) + expect((await rig.host.journalSnapshot(SESSION)).submissions).toHaveLength(1) + expect((await rig.host.journalSnapshot(otherSessionId)).submissions).toHaveLength(0) + expect(rig.compact).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts index da3629805478..11084d947164 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts @@ -163,7 +163,7 @@ it('returns a committed queued Send after its publication fails', async () => { try { expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, - replayed: false, + replayed: true, value: { queued: { messageId: id, position: 1, state: 'waiting' } } }) expect(await rig.host.send(CALLER, params)).toMatchObject({ ok: true, replayed: true }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts index 558fadcb229e..f3737ba10865 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-resend-answer.test.ts @@ -389,33 +389,39 @@ describe('send receipt acceptance', () => { expect(dispatch).toHaveBeenCalledTimes(1) }) - it.each([false, true])( - 'returns the committed send after publication fails, even if reload fails (%s)', - async (reloadFails) => { - await attach() - const params = sendParams('accepted before publication failed') - const journal = hostJournal() - const observe = vi.spyOn(journal, 'refreshCommittedState') - if (reloadFails) { - observe.mockRejectedValueOnce(new Error('reload unavailable')) - } - journal.observeCommits( - vi.fn().mockImplementationOnce(() => { - throw new Error('publication failed') - }) - ) - await expect(host.send(CALLER, params)).resolves.toMatchObject({ - ok: true, - replayed: false, - value: { submission: { dispatchState: 'pending' } } + it('returns the committed send through receipt replay after publication fails', async () => { + await attach() + const params = sendParams('accepted before publication failed') + const journal = hostJournal() + const error = new Error('publication failed') + journal.observeCommits( + vi.fn().mockImplementationOnce(() => { + throw error }) - expect(observe).toHaveBeenCalledOnce() - expect(journal.submissions()).toHaveLength(1) - await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) - journal.observeCommits(() => {}) - await deliveredOnce() - } - ) + ) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ + ok: true, + replayed: true, + value: { submission: { dispatchState: 'pending' } } + }) + expect(hostTestState().log.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + fields: expect.objectContaining({ scope: 'send-journal-write', error }) + }), + expect.objectContaining({ + fields: expect.objectContaining({ + scope: 'command-receipt-publication', + refusal: 'agent_session_operation_invalid' + }) + }) + ]) + ) + expect(journal.submissions()).toHaveLength(1) + await expect(host.send(CALLER, params)).resolves.toMatchObject({ ok: true, replayed: true }) + journal.observeCommits(() => {}) + await deliveredOnce() + }) }) it('keeps a same-fingerprint ledger row unchanged while writing the Send receipt', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index 29ad96ec562c..e500766b9f19 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -160,9 +160,6 @@ export async function performSend( ) } if (existing) { - if (ctx.operationReceipt) { - await ctx.journal.commitSubmissionReceipt(input.clientMessageId, ctx.operationReceipt) - } return { ok: true, value: { clientMessageId: input.clientMessageId, submission: existing } @@ -178,6 +175,11 @@ export async function performSend( if (error instanceof CommandReceiptExistsError) { throw error } + ctx.logger.warn('recording a message failed', { + scope: 'send-journal-write', + sessionId: ctx.sessionId, + error + }) if (isAgentSessionAttachmentExpiredError(error)) { return agentSessionAttachmentExpiredRefusal() } diff --git a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts index 98c04a2e8401..0fb2b44b11eb 100644 --- a/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts +++ b/src/main/native-chat/agent-session-wire/structured-conversation-compaction.ts @@ -149,11 +149,7 @@ function acceptStructuredConversationCommand( if (blocked) { return blocked } - const accepted = await plan.run(ctx) - if (accepted.ok) { - context.wakeDelivery(ctx.sessionId) - } - return accepted + return plan.run(ctx) } }, sendPreparation(context, params.envelope) diff --git a/src/main/runtime/agent-session-operation-admission.test.ts b/src/main/runtime/agent-session-operation-admission.test.ts index 41df221ad122..a95c7a4cfd90 100644 --- a/src/main/runtime/agent-session-operation-admission.test.ts +++ b/src/main/runtime/agent-session-operation-admission.test.ts @@ -1,8 +1,14 @@ import { describe, expect, it } from 'vitest' -import { agentSessionOperationKey } from '../../shared/agent-session-operation-ledger' +import { + agentSessionOperationKey, + agentSessionOperationExpiry, + pruneAgentSessionOperationRows, + type AgentSessionOperationRow +} from '../../shared/agent-session-operation-ledger' import { admitAgentSessionGlobalOperationRow, - admitAgentSessionOperationRow + admitAgentSessionOperationRow, + insertAcceptedAgentSessionOperationInto } from './agent-session-operation-admission' const NOW = 1_900_000_000_000 @@ -55,3 +61,24 @@ describe('global agent-session operation admission', () => { }) }) }) + +it('expires the compatibility success row from host time even for a far-future id', () => { + const operationId = `${9_999_999_999_999}-${'b'.repeat(32)}` + const state = { operations: new Map() } + insertAcceptedAgentSessionOperationInto(state, { + callerKey: 'client', + operationId, + fingerprint: 'accepted', + now: NOW, + operationIdScope: 'global', + outcome: { status: 'succeeded', sessionId: 'chat' } + }) + const expiresAt = agentSessionOperationExpiry(NOW, NOW) + expect(state.operations.get(agentSessionOperationKey('client', operationId))).toMatchObject({ + recordedAt: NOW, + expiresAt, + outcome: { status: 'succeeded' } + }) + expect(pruneAgentSessionOperationRows(state.operations, expiresAt - 1).size).toBe(1) + expect(pruneAgentSessionOperationRows(state.operations, expiresAt).size).toBe(0) +}) diff --git a/src/main/runtime/agent-session-operation-admission.ts b/src/main/runtime/agent-session-operation-admission.ts index c3938be827ba..a37b263d72a4 100644 --- a/src/main/runtime/agent-session-operation-admission.ts +++ b/src/main/runtime/agent-session-operation-admission.ts @@ -3,6 +3,7 @@ import { agentSessionOperationKey, + agentSessionOperationExpiry, claimAgentSessionOperation, evaluateAgentSessionOperation, findAgentSessionGlobalOperationRow, @@ -36,7 +37,7 @@ export type AgentSessionOperationAcceptance = AgentSessionOperationAdmission & { operationIdScope?: 'global' } -/** No admission policy: the acceptance transaction preserves only cross-family identity. */ +// TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. export function insertAcceptedAgentSessionOperationInto( state: Pick, args: AgentSessionOperationAcceptance & { outcome: AgentSessionOperationOutcome } @@ -66,6 +67,7 @@ export function insertAcceptedAgentSessionOperationInto( if (!exists) { state.operations.set(agentSessionOperationKey(args.callerKey, args.operationId), { ...pendingAgentSessionOperationRow(args), + expiresAt: agentSessionOperationExpiry(args.now, args.now), outcome: args.outcome }) } diff --git a/src/main/runtime/agent-session-operation-receipt.ts b/src/main/runtime/agent-session-operation-receipt.ts index 5690634c99a7..dff02a2e0085 100644 --- a/src/main/runtime/agent-session-operation-receipt.ts +++ b/src/main/runtime/agent-session-operation-receipt.ts @@ -16,7 +16,7 @@ export function agentSessionOperationOutcomeReceipt( args: AgentSessionOperationReceiptInput ): JournalOperationReceipt { if ('fingerprint' in args) { - // TEMPORARY until native-chat writes stop using the ledger: preserve cross-family conflicts. + // TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. return transactions.receipt((draft) => insertAcceptedAgentSessionOperationInto(draft, args), { insertOperationsIfAbsent: true }) diff --git a/src/main/runtime/agent-session-record-rows.ts b/src/main/runtime/agent-session-record-rows.ts index ed966d0b34be..e819a1ebbf6c 100644 --- a/src/main/runtime/agent-session-record-rows.ts +++ b/src/main/runtime/agent-session-record-rows.ts @@ -179,7 +179,7 @@ function writeTabIndex( } } -/** Compatibility receipts may add a ledger row, never replace an existing operation. */ +// TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. export function insertAgentSessionOperationRowsIfAbsent( db: Database.Database, rows: AgentSessionStoreRowWrites['operations']['upsert'] diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index ed20d364f865..5cc0713a36bf 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -149,6 +149,7 @@ export class AgentSessionStoreTransactions { staged = this.stage(apply) const writes = staged.writes if (writes) { + // TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. if (options.insertOperationsIfAbsent) { insertAgentSessionOperationRowsIfAbsent(db, writes.operations.upsert) } else { From dfe3c3f5c9bf239ed35cf2a62d32a78e182800a7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:19:17 -0700 Subject: [PATCH 06/11] test(native-chat): expect receipt answers where tests assumed the send ledger Restart-continuation tests now model the case Send cannot vouch for (saved but not loaded into memory) and keep it unconfirmed; a later throw after the message is in the chat answers continued, as a resend would. Drop /compact's unreachable legacy replay branch and fix stale comments. --- .../command-receipt-schema.ts | 2 +- ...structured-agent-session-mutation-plans.ts | 8 ++--- ...ructured-agent-session-queued-gate.test.ts | 14 ++++----- ...ed-agent-session-queued-pause-lift.test.ts | 15 +++++----- ...ctured-agent-session-refusal-retry.test.ts | 30 ++++++++++++++----- ...ent-session-restart-failure-filing.test.ts | 6 ++-- ...ssion-restart-interruption-test-harness.ts | 19 +++++++++++- ...ed-agent-session-restart-ownership.test.ts | 26 ++++++++++++++-- ...nt-session-restart-resume-progress.test.ts | 6 ++-- .../runtime/agent-session-record-store.ts | 4 +-- 10 files changed, 90 insertions(+), 40 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts index cc8aab79eb18..2502300785a0 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts @@ -79,7 +79,7 @@ export const commandReceiptSchema = z.discriminatedUnion('status', [ export type CommandReceipt = z.infer -/** Additive and inert until cutover; a version bump here would prevent older builds writing. */ +/** Additive, so no version bump: one would stop older builds writing this database. */ export function ensureCommandReceiptsTable(db: Database.Database): void { db.exec(` CREATE TABLE IF NOT EXISTS agent_session_command_receipts ( diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 30d176c5e9ac..19acaef07638 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -1,7 +1,8 @@ // One plan per mutating method: what it fingerprints, what it does, and how its // answer is rebuilt on a replay. // -// The receipt proves acceptance; the journal projects its current answer. +// Send and /compact prove acceptance by their command receipt, the rest by their ledger row; the +// journal projects the current answer. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { @@ -189,10 +190,7 @@ export function conversationCommandPlan(params: { }) return sent.ok ? { ok: true, value: { clientMessageId } } : sent }, - replay: (ctx, outcome) => { - if (outcome.status === 'succeeded' && outcome.conversationCommand) { - return { recorded: outcome.conversationCommand } - } + replay: (ctx) => { if (ctx.journal.submissions().some((entry) => entry.clientMessageId === clientMessageId)) { return { clientMessageId } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts index 09ba1797b29a..b9f6e3167920 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { structuredQueueHold } from './structured-agent-session-queued-messages' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createQueuedMessageTestRig, eventually, @@ -230,7 +231,7 @@ describe('the hand-off link on answers', () => { expect(await submission(working)).not.toHaveProperty('queuedMessageId') }) - it('a queued send asked again after its ledger row is gone answers with its hand-off, never sending twice', async () => { + it('a queued send asked again after its receipt is gone answers with its hand-off, never sending twice', async () => { const working = await workingSend() const body = hostTestMessage('asked again') const clientOperationId = hostTestOperationId() @@ -247,13 +248,10 @@ describe('the hand-off link on answers', () => { await settleAccepted(working, 'a') await eventually(async () => expect(await rig.handoff(clientOperationId)).toBeDefined()) const handedOffAs = await rig.handoffId(clientOperationId) - // The ledger forgot the id, so the send runs again rather than replaying. - const operations = store['transactions'].state.operations - for (const [key, row] of operations) { - if (row.operationId === clientOperationId) { - operations.delete(key) - } - } + // As an older build accepted it, with no receipt: the send runs again rather than replaying. + openTestJournalHostDatabase(rig.root) + .db.prepare('DELETE FROM agent_session_command_receipts WHERE operation_id = ?') + .run(clientOperationId) const count = (await host.journalSnapshot(SESSION)).submissions.length expect(await host.send(CALLER, params)).toMatchObject({ ok: true, diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts index 418c51f5cc74..75ff328b012e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { AGENT_JOURNAL_THREAD_SCOPE } from '../../../shared/agent-session-journal-types' import { HOST_TEST_SESSION, @@ -168,18 +169,16 @@ describe("a Stop's queue pause", () => { await expectPaused(heldId) }) - it('an old send answered again after its ledger row is gone lifts nothing from a later Stop', async () => { + it('an old send answered again after its receipt is gone lifts nothing from a later Stop', async () => { const working = await rig.workingSend() const draftId = await queuedDraft('paused by stop') await rig.stop() await rig.settleAccepted(working, 'stopped') - // The ledger forgot the id, so the send runs again and answers with its accepted submission. - const operations = rig.store['transactions'].state.operations - for (const [key, row] of operations) { - if (row.operationId === working) { - operations.delete(key) - } - } + // As an older build accepted it, with no receipt: the send runs again and answers with its + // accepted submission. + openTestJournalHostDatabase(rig.root) + .db.prepare('DELETE FROM agent_session_command_receipts WHERE operation_id = ?') + .run(working) const body = hostTestMessage('work on this') const replayed = await rig.host.send(QUEUED_RIG_CALLER, { envelope: rig.envelope({ body }, 'agentSession.send', working), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts index 2a201e94ef0a..01639485bd25 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-refusal-retry.test.ts @@ -170,7 +170,11 @@ async function assertHostAgreement( } const outcome = operationState(harness, spec.operationId) let oracle: AgentSessionRefusalOperationState - if (outcome?.status === 'failed') { + if ( + harness.store.readCommandReceipt({ kind: 'global' }, spec.operationId).verdict === 'unreadable' + ) { + oracle = 'unknown' + } else if (outcome?.status === 'failed') { oracle = 'settled-rejected' } else if (outcome?.status === 'unknown') { oracle = 'unknown' @@ -211,8 +215,6 @@ const UNREACHABLE = new Set([ 'agentSession.send:agent_session_identity_required', // Only a send opens the conversation it writes to. 'agentSession.setOption:agent_session_journal_unreadable', - // Send reconstructs doubt from its global tombstone instead of refusing it. - 'agentSession.send:agent_session_operation_unknown', // Only a send restarts a lost owner. 'agentSession.setOption:agent_session_owner_restart_failed', // A write names its target, not an owner generation; only an attach compares fences. @@ -223,6 +225,8 @@ const UNREACHABLE = new Set([ 'agentSession.send:agent_session_conflict', 'agentSession.send:execution_owner_reconciling', 'agentSession.send:agent_session_owner_restart_failed', + // Send admits through its receipt, which has no age budget. + 'agentSession.send:agent_session_operation_expired', // The ledger has no count limit; only an older host still refuses with it. 'agentSession.setOption:agent_session_operation_capacity', 'agentSession.send:agent_session_operation_capacity' @@ -285,11 +289,15 @@ describe('agentSessionRefusalOperationState host oracle', () => { ) } const ledgerRefusals = await createHarness() - for (const [code, timestamp] of [ - ['agent_session_operation_expired', NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1], - ['agent_session_operation_invalid', null] + for (const [code, timestamp, methods] of [ + [ + 'agent_session_operation_expired', + NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, + ['agentSession.setOption'] + ], + ['agent_session_operation_invalid', null, METHODS] ] as const) { - for (const method of METHODS) { + for (const method of methods) { record( await assertHostAgreement( ledgerRefusals, @@ -308,6 +316,14 @@ describe('agentSessionRefusalOperationState host oracle', () => { const optionUnknown = { method: 'agentSession.setOption' as const, operationId: operationId() } await expect(invoke(unknown, optionUnknown)).rejects.toThrow('reply lost') record(await assertHostAgreement(unknown, optionUnknown, 'agent_session_operation_unknown')) + const sendUnknown = { method: 'agentSession.send' as const, operationId: operationId() } + await expect(invoke(unknown, sendUnknown)).resolves.toMatchObject({ ok: true }) + openTestJournalHostDatabase(unknown.root) + .db.prepare( + "UPDATE agent_session_command_receipts SET result_json = '{' WHERE operation_id = ?" + ) + .run(sendUnknown.operationId) + record(await assertHostAgreement(unknown, sendUnknown, 'agent_session_operation_unknown')) const reconciling = await createHarness() for (const method of ['agentSession.setOption'] as const) { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts index c99cc945d31c..be5b153ecc87 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-failure-filing.test.ts @@ -11,7 +11,7 @@ import { QUIT_CUT_NOTICE, readerNotes, statusNotes, - throwAfterContinuationAccepted + continuationCannotFold } from './structured-agent-session-restart-interruption-test-harness' import { CALLER, envelope } from './structured-agent-session-host-test-harness' import { @@ -88,13 +88,13 @@ it("keeps the quit's notice through a continuation that carries the chat on", as ]) }) -// A send that throws after Orca may have taken it cannot be proven undelivered: filed unconfirmed, +// A message Orca took but cannot read back cannot be proven undelivered: filed unconfirmed, // and it stays on record while the agent that may be carrying on keeps running. it('keeps an unconfirmed failure while the agent the continuation started keeps running', async () => { const { host } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) await host.restartResume.list() - throwAfterContinuationAccepted() + continuationCannotFold() const result = await host.restartResume.continueAfterRestart([SESSION], 'modal') diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts index e17315b43e83..db9107193361 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-interruption-test-harness.ts @@ -18,6 +18,7 @@ import { openTestAgentSessionRecordStore } from '../../runtime/agent-session-rec import { parseAgentSessionResumeMarker } from '../../../shared/agent-session-resume-marker' import type { AgentChildWorkView } from '../../../shared/agent-status-child-work-view' import { AgentSessionJournal } from '../agent-session-journal/journal-store' +import * as reducer from '../agent-session-journal/journal-reducer' import { StructuredAgentSessionHost } from './structured-agent-session-host' import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types' import { StructuredAgentSessionResumeAdmission } from './structured-agent-session-restart-resume-runner' @@ -190,7 +191,7 @@ export async function interruptedRestart( return { ...hostTestState(), host, store, log, closeSession, marker, clock } } -/** The continuation's submission commits, then its send throws: a send Orca may have taken. +/** The continuation's submission commits and folds, then its send throws: a resend answers it. * Install it right before the continuation, the only submission written from then on. */ export function throwAfterContinuationAccepted(): void { const append = AgentSessionJournal.prototype.appendSubmission @@ -203,6 +204,22 @@ export function throwAfterContinuationAccepted(): void { }) } +/** The continuation's submission commits but cannot fold: Orca took a message it cannot vouch + * for. Install it right before the continuation, the only submission written from then on. */ +export function continuationCannotFold(): void { + const fold = reducer.applyJournalRow + vi.spyOn(reducer, 'applyJournalRow').mockImplementation((state, row) => { + if (row.kind !== 'submission') { + fold(state, row) + return + } + // As the reducer does before anything can throw, so later appends take later sequences. + state.lastSequence = Math.max(state.lastSequence, row.seq) + state.highestFence = Math.max(state.highestFence, row.fence) + throw new Error('the accepted continuation could not fold') + }) +} + export async function statusNotes(host: StructuredAgentSessionHost) { return notesOf((await host.journalSnapshot(SESSION)).items) } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts index 8614f4353200..38391833ff25 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-ownership.test.ts @@ -19,6 +19,7 @@ import { QUIT_CUT_NOTICE, startAgent, statusNotes, + continuationCannotFold, supersededRefusal, throwAfterContinuationAccepted } from './structured-agent-session-restart-interruption-test-harness' @@ -146,11 +147,11 @@ it('replays the same logical continuation through the durable send ledger', asyn }) // A send that throws after Orca may have taken it is not proof it was not delivered. -it('keeps a continuation unconfirmed when its send throws after acceptance', async () => { +it('keeps a continuation unconfirmed when its accepted message cannot be read back', async () => { const { host, store } = await interruptedRestart() vi.spyOn(console, 'warn').mockImplementation(() => {}) expect(await host.restartResume.list()).toHaveLength(1) - throwAfterContinuationAccepted() + continuationCannotFold() const result = await host.restartResume .continueAfterRestart([SESSION], 'modal') @@ -171,6 +172,27 @@ it('keeps a continuation unconfirmed when its send throws after acceptance', asy ).toMatchObject({ outcome: { status: 'succeeded' } }) }) +// A throw after the message is in the chat is answered as a resend would be: it was delivered. +it('reports a continuation delivered when its send throws after its message is in the chat', async () => { + const { host, dispatch } = await interruptedRestart() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(await host.restartResume.list()).toHaveLength(1) + throwAfterContinuationAccepted() + + const result = await host.restartResume + .continueAfterRestart([SESSION], 'modal') + .finally(() => vi.restoreAllMocks()) + + expect(result.continued).toMatchObject([{ sessionId: SESSION, outcome: 'continued' }]) + expect(result.failed).toEqual([]) + expect(dispatch).toHaveBeenCalledOnce() + const notes = await statusNotes(host) + expect(notes).toContainEqual({ text: AGENT_SESSION_RESTART_CONTINUATION_NOTE, tone: undefined }) + expect(notes.map((note) => note.text)).not.toContain( + AGENT_SESSION_RESTART_CONTINUATION_UNCONFIRMED_NOTE + ) +}) + // The continuation is accepted, then its start fails: the message is rejected with the cause and // the failure is filed, and nothing is stopped because nothing started. it('rejects the continuation when its start fails, and files a retryable refusal', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts index cb38b4fdce06..5ff3dc4aa651 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-restart-resume-progress.test.ts @@ -4,8 +4,8 @@ import type { AgentSessionStatusSummary } from '../../../shared/agent-session-wi import { AgentSessionRecoveryCapsule } from '../../runtime/agent-session-recovery-capsule' import { interruptedRestart, - startAgent, - throwAfterContinuationAccepted + continuationCannotFold, + startAgent } from './structured-agent-session-restart-interruption-test-harness' import { HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' import { createRestartResumeProgress } from './structured-agent-session-restart-resume-progress' @@ -178,7 +178,7 @@ it.each(['continued', 'refused', 'unconfirmed'] as const)( acquire.mockRejectedValueOnce(new Error('start refused')) } if (phase === 'unconfirmed') { - throwAfterContinuationAccepted() + continuationCannotFold() } const bookkeeping = Promise.withResolvers() const complete = AgentSessionRecoveryCapsule.prototype.completeResume diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 9660852deab1..f1e82e27503e 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -310,8 +310,8 @@ export class AgentSessionRecordStore { await this.transact((draft) => settleAgentSessionOperationInto(draft, args)) } - /** The same settlement, committed by the journal write that makes it true. It changes only the - * ledger, so no record listener is owed. */ + /** That settlement, or an accepted row inserted if absent, committed by the journal write that + * makes it true. It changes only the ledger, so no record listener is owed. */ operationOutcomeReceipt = (args: Parameters[1]) => agentSessionOperationOutcomeReceipt(this.transactions, args) From e1298b1c69cd795fa33205943ef3826dfe632e1f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:36:51 -0700 Subject: [PATCH 07/11] test: run the queued tests that open the journal database in the Node runtime project --- config/scripts/vitest-sqlite-runtime-files.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 64fd1a0181aa..59c4b33a558a 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -100,7 +100,10 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-repeat-press.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-read-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-readable-restorer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-reasoning-sweep.test.ts', From e0f75ba4768ad1436bb2d1d9f7de6bb4e7a57924 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:14:56 -0700 Subject: [PATCH 08/11] fix(native-chat): queued-message actions and prompt answers accept through their own receipt Send Now, Delete, Resume and prompt answers move onto the command-receipt route Send uses: the receipt is inserted with the command's own write. A no-op answers only once its receipt commits (otherwise outcome unknown), and a prompt answer's replay reads the exact revision it was accepted at. --- .../scripts/vitest-sqlite-runtime-files.mjs | 2 + .../command-receipt-schema.ts | 14 +- .../journal-item-appender.ts | 11 +- .../journal-item-revision-read.ts | 56 ++ .../journal-queued-messages.ts | 28 +- .../journal-row-writer.ts | 15 + .../journal-store-collaborators.ts | 7 +- .../agent-session-journal/journal-store.ts | 15 +- ...uctured-agent-session-command-admission.ts | 21 +- ...tructured-agent-session-command-receipt.ts | 192 +++++-- .../structured-agent-session-host.test.ts | 6 +- ...ctured-agent-session-mutation-admission.ts | 6 +- ...structured-agent-session-mutation-plans.ts | 80 ++- ...agent-session-operation-settlement.test.ts | 41 +- ...ured-agent-session-operation-settlement.ts | 2 +- ...ent-session-prompt-answer-receipts.test.ts | 330 +++++++++++ ...ent-session-queued-action-receipts.test.ts | 536 ++++++++++++++++++ ...ructured-agent-session-queued-mutations.ts | 109 ++-- .../structured-agent-session-queued-pause.ts | 6 +- .../structured-agent-session-turns-prompt.ts | 28 +- .../structured-agent-session-turns.ts | 2 +- .../runtime/agent-session-record-store.ts | 4 + .../agent-session-store-transactions.ts | 10 + src/shared/agent-session-mutation-envelope.ts | 4 +- 24 files changed, 1367 insertions(+), 158 deletions(-) create mode 100644 src/main/native-chat/agent-session-journal/journal-item-revision-read.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts create mode 100644 src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts diff --git a/config/scripts/vitest-sqlite-runtime-files.mjs b/config/scripts/vitest-sqlite-runtime-files.mjs index 459796e5d3d2..c109bc9101a7 100644 --- a/config/scripts/vitest-sqlite-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-runtime-files.mjs @@ -101,11 +101,13 @@ export const SQLITE_RUNTIME_INCLUDE = [ 'src/main/native-chat/agent-session-wire/structured-agent-session-owed-work-release.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-pre-spawn-first-answer.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-processless-reservation.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-prompt-cancel.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-child-record.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-restore.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started-catalog.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-provider-started.test.ts', + 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-gate.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-newer-orca.test.ts', 'src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause-lift.test.ts', diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts index 2502300785a0..28b9c33a4f25 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts @@ -29,6 +29,13 @@ export const commandReceiptResultSchema = z.discriminatedUnion('kind', [ kind: z.literal('queued-draft'), messageId: z.string().min(1) }), + /** An earlier operation's revision this command acknowledged instead of writing its own. */ + z.strictObject({ + kind: z.literal('item-revision'), + epoch: z.string().min(1), + itemId: z.string().min(1), + revision: z.int().nonnegative() + }), z.strictObject({ kind: z.literal('no-op'), outcome: z.discriminatedUnion('kind', [ @@ -37,7 +44,12 @@ export const commandReceiptResultSchema = z.discriminatedUnion('kind', [ cancelled: z.literal(false), turnId: z.string().optional() }), - z.strictObject({ kind: z.literal('queue-resume'), resumed: z.literal(false) }) + z.strictObject({ kind: z.literal('queue-resume'), resumed: z.literal(false) }), + z.strictObject({ + kind: z.literal('queue-delete'), + messageId: z.string().min(1), + disposition: z.enum(['dispatched', 'withdrawn', 'missing']) + }) ]) }) ]) diff --git a/src/main/native-chat/agent-session-journal/journal-item-appender.ts b/src/main/native-chat/agent-session-journal/journal-item-appender.ts index 146078d830c9..836f851246a4 100644 --- a/src/main/native-chat/agent-session-journal/journal-item-appender.ts +++ b/src/main/native-chat/agent-session-journal/journal-item-appender.ts @@ -7,6 +7,7 @@ import { journalItemRowBuilder } from './journal-row-builders' import type { JournalReducerState } from './journal-reducer' import type { JournalAppendResult, JournalItemAppendOptions } from './journal-store-contracts' import type { JournalRow } from './journal-row-schema' +import type { JournalOperationReceipt } from './journal-row-writer' const NOTHING_RESOLVED = new Error('journal_item_resolved_to_nothing') @@ -16,18 +17,22 @@ export class JournalItemAppender { constructor( private readonly deps: { state: () => JournalReducerState - enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueue: ( + build: (seq: number, ts: number) => JournalRow, + receipt?: JournalOperationReceipt + ) => Promise } ) {} append( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: JournalItemAppendOptions + options: JournalItemAppendOptions, + receipt?: JournalOperationReceipt ): Promise { const itemId = agentJournalItemKey(identity) return this.deps - .enqueue(journalItemRowBuilder(this.deps.state, identity, body, options)) + .enqueue(journalItemRowBuilder(this.deps.state, identity, body, options), receipt) .then((row) => appendResult(row, itemId)) } diff --git a/src/main/native-chat/agent-session-journal/journal-item-revision-read.ts b/src/main/native-chat/agent-session-journal/journal-item-revision-read.ts new file mode 100644 index 000000000000..2ef5779ad980 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/journal-item-revision-read.ts @@ -0,0 +1,56 @@ +// An item as one write left it, read back from the epoch that wrote it: a replay answers with the +// revision a command was accepted at, never the item's latest fold. A retired epoch's rows are +// deleted with it, so a pointer into one reads nothing. + +import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import type { AgentSessionJournal } from './journal-store' + +export type JournalItemRevisionPointer = + | { epoch: string; sequence: number } + | { epoch: string; itemId: string; revision: number } + +export type JournalItemRevision = { itemId: string; revision: number; body: AgentJournalItemBody } + +type RevisionSource = Pick + +const SCAN_PAGE = 256 + +export function readJournalItemRevision( + journal: RevisionSource, + at: JournalItemRevisionPointer +): JournalItemRevision | null { + if (at.epoch !== journal.epoch) { + return null + } + if ('sequence' in at) { + const read = journal.readSince({ epoch: at.epoch, sequence: at.sequence - 1 }, 1) + const row = read.ok ? read.rows[0] : undefined + return row?.kind === 'item' && row.seq === at.sequence + ? { itemId: row.itemId, revision: row.revision, body: row.body } + : null + } + const target = journal.canonicalItemId(at.itemId) + const folded = journal.item(target) + if (folded?.revision === at.revision) { + return { itemId: at.itemId, revision: at.revision, body: folded.body } + } + // A later revision replaced the fold's body; only the epoch's rows still hold this one. + for (let after = 0; ;) { + const read = journal.readSince({ epoch: at.epoch, sequence: after }, SCAN_PAGE) + const rows = read.ok ? read.rows : [] + const row = rows.find( + (entry) => + entry.kind === 'item' && + entry.revision === at.revision && + journal.canonicalItemId(entry.itemId) === target + ) + if (row?.kind === 'item') { + return { itemId: at.itemId, revision: at.revision, body: row.body } + } + const last = rows.at(-1) + if (!last || rows.length < SCAN_PAGE) { + return null + } + after = last.seq + } +} diff --git a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts index 3337727689cd..201b3ad7fe24 100644 --- a/src/main/native-chat/agent-session-journal/journal-queued-messages.ts +++ b/src/main/native-chat/agent-session-journal/journal-queued-messages.ts @@ -16,7 +16,7 @@ import { import type { JournalHostDatabase } from './journal-host-database' import type { JournalReducerState } from './journal-reducer' import type { JournalRow } from './journal-row-schema' -import type { JournalOperationReceipt } from './journal-row-writer' +import { writeReceiptIfChanged, type JournalOperationReceipt } from './journal-row-writer' import type { JournalSubmissionConsume } from './journal-store-contracts' import { holdQueuedMessages } from './queued-message-holds' import { @@ -145,11 +145,10 @@ export class JournalQueuedMessages { queuedAt: { epoch, sequence: lastSequence }, now: this.deps.now() }) - receipt?.write(db) return row }, () => inserted, - receipt?.committed + receipt ) } @@ -219,11 +218,11 @@ export class JournalQueuedMessages { } /** Compare-and-transition waiting ∪ returned rows to op-stamped tombstones, - * kept only so a replay of the settling operation answers "spent". */ - withdraw(input: { - messageIds: readonly string[] - settledByOp: string - }): Promise { + * kept only so a replay of the settling operation answers "spent". `receipt`: with a withdrawal. */ + withdraw( + input: { messageIds: readonly string[]; settledByOp: string }, + receipt?: JournalOperationReceipt + ): Promise { if (input.messageIds.length === 0) { // Delete races and empty carries land here; neither may cost a write transaction. return Promise.resolve([]) @@ -235,7 +234,8 @@ export class JournalQueuedMessages { sessionId: this.deps.sessionId, now: this.deps.now() }), - (withdrawn) => withdrawn.length > 0 + (withdrawn) => withdrawn.length > 0, + receipt ) } @@ -254,18 +254,18 @@ export class JournalQueuedMessages { }).length } - /** One standalone draft-table transaction on the journal's queue; one that - * changed rows bumps the revision and notifies after COMMIT, `adopted` first. */ + /** One standalone draft-table transaction on the journal's queue; one that changed rows commits + * `receipt` with them, bumps the revision and notifies after COMMIT, the receipt first. */ private transact( run: (db: Database.Database) => JournalWriteResult, changed: (result: T) => boolean, - adopted?: () => void + receipt?: JournalOperationReceipt ): Promise { return this.deps.serialize(() => { assertJournalWritable(this.deps.readOnly(), this.deps.sessionId) - const result = this.deps.database().transaction(run) + const result = this.deps.database().transaction(writeReceiptIfChanged(run, changed, receipt)) if (changed(result)) { - adopted?.() + receipt?.committed() this.changeRevision++ this.deps.committed() } diff --git a/src/main/native-chat/agent-session-journal/journal-row-writer.ts b/src/main/native-chat/agent-session-journal/journal-row-writer.ts index 9a03d9ca347f..0cd1916e13f1 100644 --- a/src/main/native-chat/agent-session-journal/journal-row-writer.ts +++ b/src/main/native-chat/agent-session-journal/journal-row-writer.ts @@ -28,6 +28,21 @@ export function composeJournalOperationReceipts( } } +/** `run`, with `receipt` written in its transaction only when `changed` says it wrote something. */ +export function writeReceiptIfChanged( + run: (db: Database.Database) => T, + changed: (result: T) => boolean, + receipt?: JournalOperationReceipt +): (db: Database.Database) => T { + return (db) => { + const result = run(db) + if (receipt && changed(result)) { + receipt.write(db) + } + return result + } +} + export type JournalRowWriterDeps = { sessionId: string now: () => number diff --git a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts index 5acba78506a4..c8be257dbbc3 100644 --- a/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts +++ b/src/main/native-chat/agent-session-journal/journal-store-collaborators.ts @@ -16,7 +16,7 @@ import { JournalQueuedMessages } from './journal-queued-messages' import { JournalStopMarks } from './journal-stop-marks' import { journalQueuePauseRestatement } from './queued-message-pause' import type { JournalReducerState } from './journal-reducer' -import { JournalRowWriter } from './journal-row-writer' +import { JournalRowWriter, type JournalOperationReceipt } from './journal-row-writer' import { JournalStepWriter } from './journal-step-writer' import { restoreJournalStore } from './journal-store-restore' import { JournalSubmissionWriter } from './journal-submission-writer' @@ -42,7 +42,10 @@ export type JournalStoreHost = { adopt: (loaded: JournalLoad) => void commit: (row: JournalRow) => void journal: () => AgentSessionJournal - enqueue: (build: (seq: number, ts: number) => JournalRow) => Promise + enqueue: ( + build: (seq: number, ts: number) => JournalRow, + receipt?: JournalOperationReceipt + ) => Promise } export type JournalStoreCollaborators = { diff --git a/src/main/native-chat/agent-session-journal/journal-store.ts b/src/main/native-chat/agent-session-journal/journal-store.ts index dc456d282d1b..e97c91176e18 100644 --- a/src/main/native-chat/agent-session-journal/journal-store.ts +++ b/src/main/native-chat/agent-session-journal/journal-store.ts @@ -129,7 +129,7 @@ export class AgentSessionJournal { }, notifyCommitted: () => this.onCommitted?.(), journal: () => this, - enqueue: (build) => this.rowWriter.enqueue(build) + enqueue: (build, receipt) => this.rowWriter.enqueue(build, undefined, receipt) }) this.rowWriter = collaborators.rowWriter this.epochController = collaborators.epochController @@ -278,9 +278,10 @@ export class AgentSessionJournal { appendItem( identity: AgentJournalItemIdentity, body: AgentJournalItemBody, - options: JournalItemAppendOptions + options: JournalItemAppendOptions, + receipt?: JournalOperationReceipt ): Promise { - return this.itemAppender.append(identity, body, options) + return this.itemAppender.append(identity, body, options, receipt) } /** An upsert whose row is chosen from the fold at its own turn in the queue; null writes nothing. */ @@ -303,8 +304,12 @@ export class AgentSessionJournal { } /** A person's Resume of the queue. */ - appendQueueResume(fence: number): Promise { - return this.rowWriter.append(journalQueueResumeRowBuilder(() => this.state, fence)) + appendQueueResume(fence: number, receipt?: JournalOperationReceipt): Promise { + return this.rowWriter.append( + journalQueueResumeRowBuilder(() => this.state, fence), + undefined, + receipt + ) } /** This open found waiting cards an earlier handle wrote (`queued-message-pause.ts`). */ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts index 2de6745fe0d2..41e433f1ca24 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-admission.ts @@ -1,5 +1,8 @@ import { parseAgentSessionOperationTimestamp } from '../../../shared/agent-session-host-authority' -import { agentSessionLedgerRefusal } from '../../../shared/agent-session-mutation-envelope' +import { + agentSessionLedgerRefusal, + refuseUnlessWriterAdmitted +} from '../../../shared/agent-session-mutation-envelope' import { agentSessionRefusalFromReference, isAgentSessionRefusalError @@ -15,6 +18,7 @@ import { refuseAgentSessionMutation } from './structured-agent-session-mutation-refusals' import type { AgentSessionMutationRequest } from './structured-agent-session-mutation-admission' +import type { MutationCommandReceipt } from './structured-agent-session-mutation-plans' import { mutationTurnContext } from './structured-agent-session-mutation-turn-context' import { runCommandReceiptMutation } from './structured-agent-session-command-receipt' import { @@ -22,10 +26,12 @@ import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome' -/** Submission-accepting plans use their effect's receipt, never a ledger reservation. */ +/** A plan with a command receipt accepts through it, committed with its effect, never a ledger + * reservation. */ export async function admitCommandReceiptMutation( request: AgentSessionMutationRequest, - hostFingerprint: string + hostFingerprint: string, + commandReceipt: MutationCommandReceipt ): Promise> { const { store, envelope, plan, callerKey } = request if (parseAgentSessionOperationTimestamp(envelope.clientOperationId) === null) { @@ -68,7 +74,11 @@ export async function admitCommandReceiptMutation( if (!journal || !current) { return refuseAgentSessionMutation(AGENT_SESSION_NOT_ATTACHED) } - // These plans write only to the conversation, whose journal enforces the execution-host fence. + // A conversation write is fenced by its journal; anything the provider must carry needs the lease. + const leaseRefusal = plan.conversationWrite ? null : refuseUnlessWriterAdmitted(current.lease) + if (leaseRefusal) { + return refuseAgentSessionMutation(leaseRefusal) + } const context = mutationTurnContext(request, journal, current) try { const outcome = await runCommandReceiptMutation({ @@ -78,6 +88,7 @@ export async function admitCommandReceiptMutation( wakeDelivery: request.wakeDelivery, envelope, plan, + commandReceipt, context }) if ('committedReceipt' in outcome) { @@ -151,7 +162,7 @@ async function answerCommandReceipt( const replay = resolveAgentSessionReplayOutcome({ operationId: envelope.clientOperationId, outcome, - reconstruct: () => plan.replay(context, outcome), + reconstruct: () => plan.replay(context, outcome, receipt.result), rerunWhenReplayMissing: plan.rerunWhenReplayMissing?.(context), recoverUnknownFromDurableState: plan.recoverUnknownFromDurableState }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts index 763506a2c319..f4f331e5835b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts @@ -1,60 +1,57 @@ import type { AgentSessionMutationEnvelope } from '../../../shared/agent-session-wire' +import { isAgentSessionRefusalError } from '../../../shared/agent-session-wire-refusals' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { commandReceiptScope, - type CommandReceipt + type CommandReceipt, + type CommandReceiptResult } from '../agent-session-journal/command-receipt-schema' -import { buildCommandReceiptTransaction } from '../agent-session-journal/command-receipt-transaction' -import { composeJournalOperationReceipts } from '../agent-session-journal/journal-row-writer' -import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { + buildCommandReceiptTransaction, + CommandReceiptExistsError +} from '../agent-session-journal/command-receipt-transaction' +import { + composeJournalOperationReceipts, + type JournalOperationReceipt +} from '../agent-session-journal/journal-row-writer' +import type { JournalRow } from '../agent-session-journal/journal-row-schema' +import type { + MutationCommandReceipt, + MutationPlan +} from './structured-agent-session-mutation-plans' +import { agentSessionOperationOutcomeUnknown } from './structured-agent-session-replay-outcome' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' -/** Acceptance identity and compatibility bookkeeping share the submission or draft's commit. */ -export async function runCommandReceiptMutation(input: { +type CommandReceiptIdentity = { store: AgentSessionRecordStore operationCallerKey: string fingerprint: string envelope: AgentSessionMutationEnvelope - plan: MutationPlan + plan: { method: string; operationIdScope?: 'global' } context: AgentSessionTurnContext - wakeDelivery?: (sessionId: string) => void -}): Promise | { committedReceipt: CommandReceipt }> { - const { store, operationCallerKey, fingerprint, envelope, plan, context, wakeDelivery } = input +} + +/** Acceptance identity and compatibility bookkeeping share the commit of the command's effect. */ +export async function runCommandReceiptMutation( + input: CommandReceiptIdentity & { + plan: MutationPlan + commandReceipt: MutationCommandReceipt + wakeDelivery?: (sessionId: string) => void + } +): Promise | { committedReceipt: CommandReceipt }> { + const { envelope, plan, context, commandReceipt, wakeDelivery } = input + const unrecorded = await commitUnrecordedNoOp(input) + if (unrecorded) { + // A differing fingerprint is answered as the conflict it is, from the uncommitted receipt. + return unrecorded === 'unknown' ? unknownOutcome(input) : { committedReceipt: unrecorded } + } let acceptedReceipt: CommandReceipt | undefined let submissionWritten = false - const receipt = composeJournalOperationReceipts( - buildCommandReceiptTransaction( - commandReceiptScope(operationCallerKey, plan.operationIdScope), - (row) => { - if (row && row.kind !== 'submission') { - throw new Error('Send acceptance requires a submission') - } - submissionWritten = row !== undefined - acceptedReceipt = { - operationId: envelope.clientOperationId, - sessionId: envelope.sessionId, - callerKey: operationCallerKey, - method: plan.method, - fingerprint, - status: 'accepted', - acceptedAt: context.now(), - result: row - ? { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } - : { kind: 'queued-draft', messageId: envelope.clientOperationId } - } - return acceptedReceipt - } - ), - // TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. - store.operationOutcomeReceipt({ - callerKey: operationCallerKey, - operationId: envelope.clientOperationId, - fingerprint, - now: context.now(), - ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), - outcome: { status: 'succeeded', sessionId: envelope.sessionId } - }) - ) + const receipt = acceptanceReceipt(input, (row) => { + submissionWritten = row?.kind === 'submission' + acceptedReceipt = acceptedCommandReceipt(input, commandReceipt.result(row)) + return acceptedReceipt + }) let committed = false try { const ran = await plan.run({ @@ -67,7 +64,14 @@ export async function runCommandReceiptMutation(input: { } } }) - if (!committed || ran.ok) { + if (!committed) { + const unwritten = ran.ok ? commandReceipt.unwritten?.(ran.value, context) : null + return unwritten && + !(await commitNoOpReceipt(input, acceptedCommandReceipt(input, unwritten))) + ? unknownOutcome(input) + : ran + } + if (ran.ok) { return ran } context.logger.warn('publishing an accepted command failed; replaying its receipt', { @@ -104,3 +108,103 @@ export async function runCommandReceiptMutation(input: { } return { committedReceipt: acceptedReceipt } } + +function acceptedCommandReceipt( + { envelope, operationCallerKey, plan, fingerprint, context }: CommandReceiptIdentity, + result: CommandReceiptResult +): CommandReceipt { + return { + operationId: envelope.clientOperationId, + sessionId: envelope.sessionId, + callerKey: operationCallerKey, + method: plan.method, + fingerprint, + status: 'accepted', + acceptedAt: context.now(), + result + } +} + +function acceptanceReceipt( + input: CommandReceiptIdentity, + receipt: (row?: JournalRow) => CommandReceipt +): JournalOperationReceipt { + const { store, operationCallerKey, envelope, plan, fingerprint, context } = input + return composeJournalOperationReceipts( + buildCommandReceiptTransaction( + commandReceiptScope(operationCallerKey, plan.operationIdScope), + receipt + ), + // TEMPORARY: ledger co-write preserves cross-family identity until every mutation uses receipts. + store.operationOutcomeReceipt({ + callerKey: operationCallerKey, + operationId: envelope.clientOperationId, + fingerprint, + now: context.now(), + ...(plan.operationIdScope ? { operationIdScope: plan.operationIdScope } : {}), + outcome: { status: 'succeeded', sessionId: envelope.sessionId } + }) + ) +} + +function unknownOutcome(input: CommandReceiptIdentity): TurnOutcome { + return { + ok: false, + refusal: agentSessionOperationOutcomeUnknown(input.envelope.clientOperationId) + } +} + +/** No-ops answered unknown because their receipt did not commit, per store and receipt key. */ +const unrecordedNoOps = new WeakMap>() + +function unrecordedKey({ operationCallerKey, plan, envelope }: CommandReceiptIdentity): string { + const scope = commandReceiptScope(operationCallerKey, plan.operationIdScope) + return `${scope.kind === 'caller' ? scope.callerKey : ''}\u0000${envelope.clientOperationId}` +} + +/** A no-op is answered only once its receipt commits, alone. An identity verdict (duplicate, + * conflict, unreadable) throws for admission to answer; any other failure is logged, and the + * decision is held so a retry in this process commits it rather than deciding again on newer + * work. A restart forgets it. */ +async function commitNoOpReceipt( + input: CommandReceiptIdentity, + receipt: CommandReceipt +): Promise { + try { + await input.store.commitOperationReceipt(acceptanceReceipt(input, () => receipt)) + return true + } catch (error) { + if ( + error instanceof CommandReceiptExistsError || + (isAgentSessionRefusalError(error) && + error.refusal.code === 'agent_session_operation_conflict') + ) { + throw error + } + input.context.logger.warn( + 'recording a command that changed nothing failed; answering unknown', + { + scope: 'command-receipt-unwritten', + sessionId: input.envelope.sessionId, + operationId: input.envelope.clientOperationId, + error + } + ) + const held = unrecordedNoOps.get(input.store) ?? new Map() + unrecordedNoOps.set(input.store, held.set(unrecordedKey(input), receipt)) + return false + } +} + +/** The no-op this id was answered unknown for, committed now; null when none is held. */ +async function commitUnrecordedNoOp( + input: CommandReceiptIdentity +): Promise { + const key = unrecordedKey(input) + const receipt = unrecordedNoOps.get(input.store)?.get(key) + if (!receipt || receipt.fingerprint !== input.fingerprint) { + return receipt ?? null + } + unrecordedNoOps.get(input.store)?.delete(key) + return (await commitNoOpReceipt(input, receipt)) ? receipt : 'unknown' +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts index e92b33aac1c1..155e6cbb1d00 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-host.test.ts @@ -460,7 +460,7 @@ describe('respondToPrompt', () => { expect(answerPrompt).not.toHaveBeenCalled() }) - it("does not turn a recorded refusal into another client's successful answer", async () => { + it("does not turn a refused answer into another client's successful answer", async () => { await attach() const prompt = await seedApproval() const rejectedFields = { @@ -485,10 +485,12 @@ describe('respondToPrompt', () => { } ) + // A refusal records nothing, so the retry is judged afresh: the prompt has moved on. expect(await host.respondToPrompt(CALLER, rejected)).toMatchObject({ ok: false, - refusal: { code: 'agent_session_operation_invalid' } + refusal: { code: 'agent_session_item_revision_stale' } }) + expect(answerPrompt).toHaveBeenCalledOnce() }) it('keeps the answer and reports it undelivered when the provider callback throws', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts index aec938b85fe6..54a1d769a001 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-admission.ts @@ -1,5 +1,5 @@ // The one route every mutating agent-session call takes: recompute the fingerprint, -// dispatch submission-accepting plans to their command receipt, otherwise admit +// accept a plan that declares a command receipt through it, otherwise admit // through the ledger and check the lease. Methods cannot grow their own admission rules. // // Ledger-backed admission is two-phase when a call brings `prepareSession`. The ledger's @@ -98,8 +98,8 @@ export async function admitAndRunAgentSessionMutation( if (conflict) { return refuseAgentSessionMutation(conflict) } - if (plan.acceptsWithCommandReceipt) { - return admitCommandReceiptMutation(request, hostFingerprint) + if (plan.commandReceipt) { + return admitCommandReceiptMutation(request, hostFingerprint, plan.commandReceipt) } if (request.prepareSession) { const ledger = request.store.evaluateMutationOperation({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 6d4520f9c9d8..ecc5fa9d16ef 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -1,8 +1,8 @@ // One plan per mutating method: what it fingerprints, what it does, and how its // answer is rebuilt on a replay. // -// Send and /compact prove acceptance by their command receipt, the rest by their ledger row; the -// journal projects the current answer. +// A plan with `commandReceipt` proves acceptance by that receipt, the rest by their ledger row. The +// journal projects the answer: a prompt's from the revision it was accepted at, others' as they stand. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { @@ -38,9 +38,14 @@ import { type AgentSessionTurnContext, type TurnOutcome } from './structured-agent-session-turns' -import type { AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' +import { + acceptedPromptAnswer, + type AgentSessionPromptRequest +} from './structured-agent-session-turns-prompt' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' +import type { CommandReceiptResult } from '../agent-session-journal/command-receipt-schema' +import type { JournalRow } from '../agent-session-journal/journal-row-schema' export type MutationPlan = { method: string @@ -51,13 +56,17 @@ export type MutationPlan = { /** Still runs, decided from the committed ledger, when its ledger row cannot be written. */ runsWithoutLedgerRow?: true run: (ctx: AgentSessionTurnContext) => Promise> - replay: (ctx: AgentSessionTurnContext, outcome: AgentSessionOperationOutcome) => TValue | null + /** `receipt`: the accepted command receipt's result, for a plan with `commandReceipt`. */ + replay: ( + ctx: AgentSessionTurnContext, + outcome: AgentSessionOperationOutcome, + receipt?: CommandReceiptResult + ) => TValue | null rerunWhenReplayMissing?: (ctx: AgentSessionTurnContext) => boolean recoverUnknownFromDurableState?: boolean } & ( | { - /** Accepts with a submission or draft; its command receipt commits in that write's transaction. */ - acceptsWithCommandReceipt: true + commandReceipt: MutationCommandReceipt settlesWithWrite?: never successReceipt?: never settledOutcome?: never @@ -65,17 +74,47 @@ export type MutationPlan = { | { /** Commits success with its row; paths without a committed receipt use fallback settlement. */ settlesWithWrite: true - acceptsWithCommandReceipt?: never + commandReceipt?: never successReceipt?: () => JournalOperationReceipt settledOutcome?: (value: TValue) => AgentSessionOperationOutcome } | { settlesWithWrite?: never - acceptsWithCommandReceipt?: never + commandReceipt?: never settledOutcome?: (value: TValue) => AgentSessionOperationOutcome } ) +/** Accepted through its own command receipt, inserted if absent in the transaction of its effect. */ +export type MutationCommandReceipt = { + /** Inside the effect's transaction: the journal row it wrote, or none for a draft-table write. */ + result: (row: JournalRow | undefined) => CommandReceiptResult + /** A run that wrote nothing: the receipt committed alone, before the answer goes out (a no-op's + * answer, or the earlier write it acknowledged), or null to record nothing. */ + unwritten?: (value: TValue, ctx: AgentSessionTurnContext) => CommandReceiptResult | null +} + +/** The journal row an accepted command wrote, which must be of the kind it accepts with. */ +export function journalRowReceiptResult( + row: JournalRow | undefined, + kind: JournalRow['kind'] +): CommandReceiptResult { + if (row?.kind !== kind) { + throw new Error(`an accepted command requires its ${kind} row`) + } + return { kind: 'journal-row', epoch: row.epoch, sequence: row.seq } +} + +/** A send accepts with its submission, or a draft held while the agent works, keyed by its id. */ +function submissionOrDraftReceipt(clientMessageId: string): MutationCommandReceipt { + return { + result: (row) => + row + ? journalRowReceiptResult(row, 'submission') + : { kind: 'queued-draft', messageId: clientMessageId } + } +} + /** Who a send is from, read off what it carries, the one place it is decided: the person's own * send, another agent's message (its body names the sender), or neither, such as a dispatch * preamble or a restart continuation. */ @@ -107,7 +146,7 @@ export function sendPlan(params: { method: 'agentSession.send', operationIdScope: 'global', conversationWrite: true, - acceptsWithCommandReceipt: true, + commandReceipt: submissionOrDraftReceipt(clientMessageId), // `delivery` joins the OPERATION fingerprint only; the submission row keeps // the body-only fingerprint the reducer's echo-aliasing recomputes. fields: { @@ -192,7 +231,7 @@ export function conversationCommandPlan(params: { method: 'agentSession.conversationCommand', operationIdScope: 'global', conversationWrite: true, - acceptsWithCommandReceipt: true, + commandReceipt: submissionOrDraftReceipt(clientMessageId), fields: { command: STRUCTURED_AGENT_SESSION_COMPACT_COMMAND, ...(params.delivery ? { delivery: params.delivery } : {}) @@ -281,17 +320,18 @@ export function promptPlan( optionId: params.optionId, answers: params.answers }, + // The resolved revision carries it; an answer the prompt already holds points at that revision. + commandReceipt: { + result: (row) => journalRowReceiptResult(row, 'item'), + unwritten: (value, ctx) => ({ + kind: 'item-revision', + epoch: ctx.journal.epoch, + itemId: value.itemId, + revision: value.revision + }) + }, run: (ctx) => performPrompt(ctx, params), - replay: (ctx) => { - const item = ctx.journal.snapshot().items.find((entry) => entry.itemId === params.itemId) - const body = item?.body - if (!item || !body || (body.kind !== 'approval' && body.kind !== 'question')) { - return null - } - return body.resolution.state === 'pending' - ? null - : { itemId: item.itemId, revision: item.revision, resolution: body.resolution } - } + replay: (ctx, _outcome, receipt) => acceptedPromptAnswer(ctx, receipt) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts index ce8e68798fbf..a3a2880e8430 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.test.ts @@ -16,12 +16,27 @@ import { HOST_TEST_THREAD as THREAD } from './structured-agent-session-host-test-data' import type { AgentSessionTurnContext } from './structured-agent-session-turns' -import { sendPlan } from './structured-agent-session-mutation-plans' +import { + sendPlan, + type MutationCommandReceipt, + type MutationPlan +} from './structured-agent-session-mutation-plans' import { runCommandReceiptMutation } from './structured-agent-session-command-receipt' import { createStructuredAgentSessionLogger } from './structured-agent-session-logger' import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' import { NO_STRUCTURED_AGENTS } from './structured-agent-session-adapter-router-test-support' +const DRAFT_RECEIPT: MutationCommandReceipt = { + result: () => ({ kind: 'queued-draft', messageId: 'draft' }) +} + +function withReceipt(plan: MutationPlan) { + if (!plan.commandReceipt) { + throw new Error('expected a plan that accepts through its command receipt') + } + return { plan, commandReceipt: plan.commandReceipt } +} + async function context(): Promise { await attach() return { @@ -68,12 +83,13 @@ it('rethrows a throw from a plan answered by its write, writing nothing and leav plan: { method: 'agentSession.send', fields: {}, - settlesWithWrite: true, + commandReceipt: DRAFT_RECEIPT, run: async () => { throw refusal }, replay: () => null - } + }, + commandReceipt: DRAFT_RECEIPT }).catch((error: unknown) => error) expect(result).toBe(refusal) expect(writes).not.toHaveBeenCalled() @@ -82,11 +98,11 @@ it('rethrows a throw from a plan answered by its write, writing nothing and leav }) it.each([ - { plan: 'answered by its write', settlesWithWrite: true as const, failures: 0 }, - { plan: 'settled after its run', settlesWithWrite: undefined, failures: 2 } + { plan: 'answered by its write', acceptsWithReceipt: true, failures: 0 }, + { plan: 'settled after its run', acceptsWithReceipt: false, failures: 2 } ])( 'preserves a proven refusal of a plan $plan through $failures failed bookkeeping writes', - async ({ settlesWithWrite, failures }) => { + async ({ acceptsWithReceipt, failures }) => { const ctx = await context() const { store, dispatch } = hostTestState() const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}) @@ -105,11 +121,12 @@ it.each([ context: ctx, plan: { method: 'agentSession.send', fields: {}, run, replay: () => null } } - const result = settlesWithWrite + const result = acceptsWithReceipt ? await runCommandReceiptMutation({ ...input, fingerprint: 'test-fingerprint', - plan: { ...input.plan, settlesWithWrite } + plan: { ...input.plan, commandReceipt: DRAFT_RECEIPT }, + commandReceipt: DRAFT_RECEIPT }) : await runSettledAgentSessionMutation(input) expect(result).toEqual(refusal) @@ -135,7 +152,7 @@ it('accepts without touching the provider', async () => { fingerprint: 'test-fingerprint', envelope: operation, context: ctx, - plan: sendPlan({ envelope: operation, body, beforeRun }) + ...withReceipt(sendPlan({ envelope: operation, body, beforeRun })) }) expect(result).toMatchObject({ ok: true }) expect(beforeRun).toHaveBeenCalledOnce() @@ -162,7 +179,7 @@ it('refuses a superseded send at acceptance, recording and dispatching nothing', fingerprint: 'test-fingerprint', envelope: operation, context: ctx, - plan: sendPlan({ envelope: operation, body, beforeRun }) + ...withReceipt(sendPlan({ envelope: operation, body, beforeRun })) }).catch((error: unknown) => error) expect(result).toBeInstanceOf(AgentSessionPreDispatchError) expect(writes).not.toHaveBeenCalled() @@ -189,10 +206,10 @@ it('returns the pre-acceptance refusal without recording a receipt', async () => fingerprint: 'test-fingerprint', envelope: operation, context: ctx, - plan: { + ...withReceipt({ ...sendPlan({ envelope: operation, body }), run: async () => ({ ok: false, refusal }) - } + }) }) ).toMatchObject({ ok: false, refusal }) expect(writes).not.toHaveBeenCalled() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts index 56c49b42d6c0..d5de0fbf324b 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-operation-settlement.ts @@ -16,7 +16,7 @@ export async function runSettledAgentSessionMutation(input: { store: AgentSessionRecordStore operationCallerKey: string envelope: AgentSessionMutationEnvelope - plan: Exclude, { acceptsWithCommandReceipt: true }> + plan: Exclude, { commandReceipt: object }> context: AgentSessionTurnContext }): Promise> { const operation = { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts new file mode 100644 index 000000000000..30788ee0b516 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts @@ -0,0 +1,330 @@ +// A prompt answer accepts through its own command receipt, committed with the resolved revision +// before the provider hears the answer: a retry answers from that receipt and the provider hears +// each answer once. + +import { beforeEach, describe, expect, it, type Mock } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import { + AGENT_JOURNAL_THREAD_SCOPE, + type AgentJournalItemBody +} from '../../../shared/agent-session-journal-types' +import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' +import type { StructuredAgentSessionHost } from './structured-agent-session-host' +import { + attach, + CALLER, + envelope, + hostTestState, + seedApproval +} from './structured-agent-session-host-test-harness' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' + +const OLD = NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 60_000 +const FUTURE = NOW + 6 * 60_000 +const CONFLICT = { + ok: false, + refusal: { code: 'agent_session_operation_conflict', details: { reason: 'operationIdReused' } } +} +const UNKNOWN = { ok: false, refusal: { code: 'agent_session_operation_unknown' } } +const SPENT = { + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'resultLost' } } +} +/** The provider identity `seedApproval` raises its prompt under. */ +const PROMPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } + +let root: string +let store: AgentSessionRecordStore +let host: StructuredAgentSessionHost +let answerPrompt: Mock +let acquire: Mock +let serial = 0 + +beforeEach(() => { + ;({ root, store, host, answerPrompt, acquire } = hostTestState()) +}) + +function opId(at = NOW): string { + serial += 1 + return `${at}-${serial.toString(16).padStart(32, 'd')}` +} + +async function seeded() { + await attach() + return seedApproval() +} + +function answer( + prompt: { itemId: string; revision: number }, + clientOperationId: string, + optionId = 'allow' +) { + const fields = { itemId: prompt.itemId, expectedRevision: prompt.revision, optionId } + return { + envelope: envelope('agentSession.respondTo:approval', fields, { clientOperationId }), + kind: 'approval' as const, + ...fields + } +} + +function receipt(id: string, callerKey = CALLER.callerKey) { + return store.readCommandReceipt({ kind: 'caller', callerKey }, id) +} + +function journal() { + const open = host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!open) { + throw new Error('expected an open conversation') + } + return open +} + +function promptBody(itemId: string): AgentJournalItemBody { + const body = journal().itemBody(itemId) + if (!body) { + throw new Error(`no item ${itemId}`) + } + return body +} + +/** The provider writes the answered prompt again, as a later echo of it would. */ +async function reviseFromProvider(itemId: string): Promise { + const body = promptBody(itemId) + if (body.kind !== 'approval') { + throw new Error('expected an approval') + } + const events = acquire.mock.calls.at(-1)?.[0].events + events?.appendItem( + PROMPT, + { + ...body, + resolution: { ...body.resolution, resolvedBy: 'provider-echo', resolvedAt: NOW + 1 } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) +} + +/** What a rewind does to the conversation: a new epoch, the prompt rebuilt into it as it stands. */ +async function rewindKeepingPrompt(itemId: string): Promise { + const fence = store.getRecord(SESSION)!.lease.runtimeFence + await journal().replaceEpochItems('handle_forked', fence, [ + { identity: PROMPT, body: promptBody(itemId), turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ]) +} + +function failReceiptWrites(): () => void { + const db = openTestJournalHostDatabase(root).db + db.exec(`CREATE TRIGGER fail_prompt_receipt BEFORE INSERT ON agent_session_command_receipts + BEGIN SELECT RAISE(ABORT, 'receipt unavailable'); END`) + return () => db.exec('DROP TRIGGER fail_prompt_receipt') +} + +async function resolution(itemId: string) { + const page = await host.history({ sessionId: SESSION, direction: 'tail' }) + const item = page.ok ? page.page.items.find((entry) => entry.itemId === itemId) : undefined + return item?.body.kind === 'approval' ? item.body.resolution.state : undefined +} + +describe('a prompt answer', () => { + it.each([ + ['older than a day', OLD], + ['six minutes ahead', FUTURE] + ])('accepts an id %s with its resolved revision', async (_case, at) => { + const prompt = await seeded() + const id = opId(at) + expect(await host.respondToPrompt(CALLER, answer(prompt, id))).toMatchObject({ + ok: true, + replayed: false, + value: { itemId: prompt.itemId, resolution: { state: 'resolved' } } + }) + expect(receipt(id)).toMatchObject({ + verdict: 'readable', + receipt: { method: 'agentSession.respondTo:approval', result: { kind: 'journal-row' } } + }) + expect(answerPrompt).toHaveBeenCalledOnce() + }) + + it('delivers once for the same id twice, in turn or at once', async () => { + const prompt = await seeded() + const params = answer(prompt, opId()) + const [first, second] = await Promise.all([ + host.respondToPrompt(CALLER, params), + host.respondToPrompt(CALLER, params) + ]) + const third = await host.respondToPrompt(CALLER, params) + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ + ok: true, + replayed: true, + value: { revision: prompt.revision + 1 } + }) + expect(third).toMatchObject({ + ok: true, + replayed: true, + value: { revision: prompt.revision + 1 } + }) + expect(answerPrompt).toHaveBeenCalledOnce() + }) + + it('refuses the same id with another choice', async () => { + const prompt = await seeded() + const id = opId() + await host.respondToPrompt(CALLER, answer(prompt, id)) + expect(await host.respondToPrompt(CALLER, answer(prompt, id, 'deny'))).toMatchObject(CONFLICT) + expect(answerPrompt).toHaveBeenCalledOnce() + }) + + it('points another id at the revision that already holds its choice and delivers nothing', async () => { + const prompt = await seeded() + await host.respondToPrompt(CALLER, answer(prompt, opId())) + const alias = opId() + const other = { callerKey: 'client-2' } + expect(await host.respondToPrompt(other, answer(prompt, alias))).toMatchObject({ + ok: true, + replayed: false, + value: { revision: prompt.revision + 1 } + }) + expect(receipt(alias, other.callerKey)).toMatchObject({ + receipt: { + result: { kind: 'item-revision', itemId: prompt.itemId, revision: prompt.revision + 1 } + } + }) + expect(await host.respondToPrompt(other, answer(prompt, alias))).toMatchObject({ + ok: true, + replayed: true + }) + expect(answerPrompt).toHaveBeenCalledOnce() + }) + + it('rolls the revision back when its receipt cannot be saved', async () => { + const prompt = await seeded() + const id = opId() + const restore = failReceiptWrites() + await expect(host.respondToPrompt(CALLER, answer(prompt, id))).rejects.toThrow( + 'receipt unavailable' + ) + restore() + expect(await resolution(prompt.itemId)).toBe('pending') + expect(receipt(id)).toEqual({ verdict: 'absent' }) + expect(store.listOperationRows().find((row) => row.operationId === id)).toBeUndefined() + }) + + it('answers an unreadable receipt as unknown without asking the provider', async () => { + const prompt = await seeded() + const id = opId() + openTestJournalHostDatabase(root) + .db.prepare( + `INSERT INTO agent_session_command_receipts (operation_id, session_id, caller_key, method, + fingerprint, status, result_json, rejection_json, accepted_at) + VALUES (?, ?, ?, 'agentSession.respondTo:approval', 'x', 'accepted', '{', NULL, 0)` + ) + .run(id, SESSION, CALLER.callerKey) + expect(await host.respondToPrompt(CALLER, answer(prompt, id))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown' } + }) + expect(answerPrompt).not.toHaveBeenCalled() + expect(await resolution(prompt.itemId)).toBe('pending') + }) + + it('records nothing for a refusal before acceptance', async () => { + const prompt = await seeded() + const id = opId() + expect(await host.respondToPrompt(CALLER, answer(prompt, id, 'deny'))).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(receipt(id)).toEqual({ verdict: 'absent' }) + expect(store.listOperationRows().find((row) => row.operationId === id)).toBeUndefined() + }) + + it('replays the resolution it was accepted with after the provider revises the prompt', async () => { + const prompt = await seeded() + const params = answer(prompt, opId()) + const first = await host.respondToPrompt(CALLER, params) + expect(first).toMatchObject({ ok: true, replayed: false }) + await reviseFromProvider(prompt.itemId) + expect(journal().item(prompt.itemId)?.revision).toBe(prompt.revision + 2) + const replay = await host.respondToPrompt(CALLER, params) + expect(replay).toEqual({ ...first, replayed: true, cursor: expect.anything() }) + expect(answerPrompt).toHaveBeenCalledOnce() + }) + + it('replays the revision another id acknowledged after the provider revises the prompt', async () => { + const prompt = await seeded() + await host.respondToPrompt(CALLER, answer(prompt, opId())) + const other = { callerKey: 'client-2' } + const params = answer(prompt, opId()) + const first = await host.respondToPrompt(other, params) + await reviseFromProvider(prompt.itemId) + const replay = await host.respondToPrompt(other, params) + expect(replay).toEqual({ ...first, replayed: true, cursor: expect.anything() }) + expect(replay).toMatchObject({ + value: { revision: prompt.revision + 1, resolution: { resolvedBy: CALLER.callerKey } } + }) + }) + + it.each([ + ['its own answer', CALLER], + ['the answer another id acknowledged', { callerKey: 'client-2' }] + ])( + 'answers a retry after a rewind as spent, never answering again: %s', + async (_case, caller) => { + const prompt = await seeded() + const params = answer(prompt, opId()) + if (caller !== CALLER) { + await host.respondToPrompt(CALLER, answer(prompt, opId())) + } + expect(await host.respondToPrompt(caller, params)).toMatchObject({ ok: true }) + await rewindKeepingPrompt(prompt.itemId) + expect(await host.respondToPrompt(caller, params)).toMatchObject(SPENT) + expect(answerPrompt).toHaveBeenCalledOnce() + } + ) + + it('answers unknown, never success, when the revision it points at cannot be recorded', async () => { + const prompt = await seeded() + await host.respondToPrompt(CALLER, answer(prompt, opId())) + const other = { callerKey: 'client-2' } + const params = answer(prompt, opId()) + const restore = failReceiptWrites() + expect(await host.respondToPrompt(other, params)).toMatchObject(UNKNOWN) + expect(await host.respondToPrompt(other, params)).toMatchObject(UNKNOWN) + restore() + await reviseFromProvider(prompt.itemId) + // The retry acknowledges the revision the first attempt saw, not the provider's later one. + expect(await host.respondToPrompt(other, params)).toMatchObject({ + ok: true, + replayed: true, + value: { revision: prompt.revision + 1 } + }) + expect(answerPrompt).toHaveBeenCalledOnce() + }) + + it('refuses a Stop reusing its id, and an answer reusing a Stop id', async () => { + const prompt = await seeded() + const answered = opId() + await host.respondToPrompt(CALLER, answer(prompt, answered)) + expect( + await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', {}, { clientOperationId: answered }) + }) + ).toMatchObject(CONFLICT) + const second = await seedApproval('approve') + const stopped = opId() + await host.cancel(CALLER, { + envelope: envelope('agentSession.cancel', {}, { clientOperationId: stopped }) + }) + expect(await host.respondToPrompt(CALLER, answer(second, stopped, 'approve'))).toMatchObject( + CONFLICT + ) + expect(receipt(stopped)).toEqual({ verdict: 'absent' }) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts new file mode 100644 index 000000000000..ede2e3fa58d6 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts @@ -0,0 +1,536 @@ +// Send now, Delete and Resume accept through their own command receipt, committed with the draft or +// row they change: no ledger policy decides them, a retry answers from that receipt, and an id +// another command already used is refused. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' +import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' +import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + createQueuedMessageTestRig, + QUEUED_RIG_CALLER as CALLER, + type QueuedMessageTestRig +} from './structured-agent-session-queued-message-rig.test-fixture' +import { + HOST_TEST_NOW as NOW, + HOST_TEST_SESSION as SESSION, + hostTestMessage +} from './structured-agent-session-host-test-data' + +const CALLER_SCOPE = { kind: 'caller', callerKey: CALLER.callerKey } as const +const OLD = NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 60_000 +const FUTURE = NOW + 6 * 60_000 + +let rig: QueuedMessageTestRig +let serial = 0 + +beforeEach(async () => { + rig = await createQueuedMessageTestRig() +}) + +afterEach(() => rig.dispose()) + +function opId(at = NOW): string { + serial += 1 + return `${at}-${serial.toString(16).padStart(32, 'e')}` +} + +async function queuedDraft(text: string): Promise { + const queued = await rig.send(text, 'queue-if-active').result + if (!queued.ok || !('queued' in queued.value)) { + throw new Error('expected a queued receipt') + } + return queued.value.queued.messageId +} + +function receipt(id: string) { + return rig.store.readCommandReceipt(CALLER_SCOPE, id) +} + +function ledgerRow(id: string) { + return rig.store.listOperationRows().find((row) => row.operationId === id) +} + +function failReceiptWrites(): () => void { + const db = openTestJournalHostDatabase(rig.root).db + db.exec(`CREATE TRIGGER fail_queued_receipt BEFORE INSERT ON agent_session_command_receipts + BEGIN SELECT RAISE(ABORT, 'receipt unavailable'); END`) + return () => db.exec('DROP TRIGGER fail_queued_receipt') +} + +function writeUnreadableReceipt(id: string, method: string): void { + openTestJournalHostDatabase(rig.root) + .db.prepare( + `INSERT INTO agent_session_command_receipts (operation_id, session_id, caller_key, method, + fingerprint, status, result_json, rejection_json, accepted_at) + VALUES (?, ?, ?, ?, 'x', 'accepted', '{', NULL, 0)` + ) + .run(id, SESSION, CALLER.callerKey, method) +} + +/** What a rewind does to the conversation: a new epoch rebuilt with none of the old rows. */ +async function rewindConversation(): Promise { + const journal = rig.host.collaboratorsForTests().sessions.get(SESSION)?.journal + if (!journal) { + throw new Error('expected an open conversation') + } + await journal.replaceEpochItems( + 'handle_forked', + rig.store.getRecord(SESSION)!.lease.runtimeFence, + [] + ) +} + +/** A stop that leaves a waiting card paused, which only a Resume lifts. */ +async function pausedQueue(): Promise { + await rig.workingSend() + await queuedDraft('held by stop') + await rig.stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) +} + +const UNKNOWN = { ok: false, refusal: { code: 'agent_session_operation_unknown' } } +const CONFLICT = { + ok: false, + refusal: { code: 'agent_session_operation_conflict', details: { reason: 'operationIdReused' } } +} + +describe('Send now', () => { + it.each([ + ['older than a day', OLD], + ['six minutes ahead', FUTURE] + ])('accepts an id %s and hands the card off once', async (_case, at) => { + await rig.workingSend() + const draftId = await queuedDraft('send me now') + const id = opId(at) + expect(await rig.sendNow(draftId, id)).toMatchObject({ + ok: true, + replayed: false, + value: { clientMessageId: id, submission: { queuedMessageId: draftId } } + }) + expect(receipt(id)).toMatchObject({ + verdict: 'readable', + receipt: { method: 'agentSession.queuedMessageSend', result: { kind: 'journal-row' } } + }) + expect(ledgerRow(id)?.outcome).toMatchObject({ status: 'succeeded' }) + }) + + it('answers the same id twice, in turn or at once, from one hand-off', async () => { + await rig.workingSend() + const draftId = await queuedDraft('send me once') + const id = opId() + const [first, second] = await Promise.all([rig.sendNow(draftId, id), rig.sendNow(draftId, id)]) + const third = await rig.sendNow(draftId, id) + expect(first).toMatchObject({ ok: true, replayed: false }) + expect(second).toMatchObject({ ok: true, replayed: true, value: { clientMessageId: id } }) + expect(third).toMatchObject({ ok: true, replayed: true, value: { clientMessageId: id } }) + const handoffs = (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === draftId + ) + expect(handoffs).toHaveLength(1) + }) + + it('points another id at the hand-off that already took the card', async () => { + await rig.workingSend() + const draftId = await queuedDraft('already sent') + const first = opId() + await rig.sendNow(draftId, first) + const alias = opId() + expect(await rig.sendNow(draftId, alias)).toMatchObject({ + ok: true, + replayed: false, + value: { clientMessageId: first } + }) + expect(receipt(alias)).toMatchObject({ + receipt: { result: { kind: 'queued-draft', messageId: draftId } } + }) + expect(await rig.sendNow(draftId, alias)).toMatchObject({ + ok: true, + replayed: true, + value: { clientMessageId: first } + }) + }) + + it('refuses the same id naming another card', async () => { + await rig.workingSend() + const first = await queuedDraft('first card') + const second = await queuedDraft('second card') + const id = opId() + await rig.sendNow(first, id) + expect(await rig.sendNow(second, id)).toMatchObject({ + ok: false, + refusal: { + code: 'agent_session_operation_conflict', + details: { reason: 'operationIdReused' } + } + }) + expect(await rig.drafts()).toContainEqual(expect.objectContaining({ messageId: second })) + }) + + it('rolls the hand-off back when its receipt cannot be saved, then sends on a retry', async () => { + await rig.workingSend() + const draftId = await queuedDraft('roll me back') + const id = opId() + const restore = failReceiptWrites() + await expect(rig.sendNow(draftId, id)).rejects.toThrow('receipt unavailable') + expect(await rig.handoff(draftId)).toBeUndefined() + expect(await rig.drafts()).toContainEqual(expect.objectContaining({ messageId: draftId })) + expect(receipt(id)).toEqual({ verdict: 'absent' }) + expect(ledgerRow(id)).toBeUndefined() + restore() + expect(await rig.sendNow(draftId, id)).toMatchObject({ ok: true, replayed: false }) + }) + + it('records nothing for a refusal before acceptance', async () => { + const id = opId() + expect(await rig.sendNow('no-such-card', id)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(receipt(id)).toEqual({ verdict: 'absent' }) + expect(ledgerRow(id)).toBeUndefined() + }) + + it('answers unknown, never success, when the hand-off it points at cannot be recorded', async () => { + await rig.workingSend() + const draftId = await queuedDraft('sent by another') + const first = opId() + await rig.sendNow(draftId, first) + const alias = opId() + const restore = failReceiptWrites() + expect(await rig.sendNow(draftId, alias)).toMatchObject(UNKNOWN) + expect(await rig.sendNow(draftId, alias)).toMatchObject(UNKNOWN) + restore() + expect(await rig.sendNow(draftId, alias)).toMatchObject({ + ok: true, + replayed: true, + value: { clientMessageId: first } + }) + expect(receipt(alias)).toMatchObject({ + receipt: { result: { kind: 'queued-draft', messageId: draftId } } + }) + }) + + it('answers an unreadable receipt as unknown and hands nothing off', async () => { + await rig.workingSend() + const draftId = await queuedDraft('unknown send') + const id = opId() + writeUnreadableReceipt(id, 'agentSession.queuedMessageSend') + expect(await rig.sendNow(draftId, id)).toMatchObject(UNKNOWN) + expect(await rig.handoff(draftId)).toBeUndefined() + }) + + it('answers a retry after a rewind dropped its hand-off as spent, and sends nothing again', async () => { + await rig.workingSend() + const draftId = await queuedDraft('rewound away') + const id = opId() + expect(await rig.sendNow(draftId, id)).toMatchObject({ ok: true, replayed: false }) + await rewindConversation() + expect(await rig.sendNow(draftId, id)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_unknown', details: { reason: 'resultLost' } } + }) + expect(await rig.handoff(draftId)).toBeUndefined() + }) +}) + +describe('Delete', () => { + it('withdraws once under an old or future id and replays the withdrawal', async () => { + await rig.workingSend() + const draftId = await queuedDraft('delete me') + const id = opId(OLD) + expect(await rig.deleteQueued(draftId, id)).toMatchObject({ + ok: true, + replayed: false, + value: { deleted: true, messageId: draftId } + }) + expect(receipt(id)).toMatchObject({ + receipt: { result: { kind: 'queued-draft', messageId: draftId } } + }) + expect(await rig.deleteQueued(draftId, id)).toMatchObject({ + ok: true, + replayed: true, + value: { deleted: true, messageId: draftId } + }) + const future = opId(FUTURE) + expect(await rig.deleteQueued(draftId, future)).toMatchObject({ + ok: true, + replayed: false, + value: { deleted: false, disposition: 'withdrawn' } + }) + }) + + it.each(['missing', 'withdrawn'] as const)( + "replays a no-op's first answer when the card was %s", + async (disposition) => { + await rig.workingSend() + const draftId = disposition === 'missing' ? 'no-such-card' : await queuedDraft('gone') + if (disposition === 'withdrawn') { + await rig.deleteQueued(draftId) + } + const id = opId() + const answer = { deleted: false, messageId: draftId, disposition } + expect(await rig.deleteQueued(draftId, id)).toMatchObject({ + ok: true, + replayed: false, + value: answer + }) + expect(receipt(id)).toMatchObject({ + receipt: { + result: { + kind: 'no-op', + outcome: { kind: 'queue-delete', messageId: draftId, disposition } + } + } + }) + expect(await rig.deleteQueued(draftId, id)).toEqual( + expect.objectContaining({ ok: true, replayed: true, value: answer }) + ) + } + ) + + it('answers concurrent copies of one id with one withdrawal', async () => { + await rig.workingSend() + const draftId = await queuedDraft('delete once') + const id = opId() + const [first, second] = await Promise.all([ + rig.deleteQueued(draftId, id), + rig.deleteQueued(draftId, id) + ]) + expect(first).toMatchObject({ ok: true, replayed: false, value: { deleted: true } }) + expect(second).toMatchObject({ ok: true, replayed: true, value: { deleted: true } }) + }) + + it('keeps the card when its receipt cannot be saved', async () => { + await rig.workingSend() + const draftId = await queuedDraft('keep me') + const id = opId() + const restore = failReceiptWrites() + await expect(rig.deleteQueued(draftId, id)).rejects.toThrow('receipt unavailable') + restore() + expect(await rig.drafts()).toContainEqual( + expect.objectContaining({ messageId: draftId, state: 'waiting' }) + ) + expect(ledgerRow(id)).toBeUndefined() + }) + + it('answers an unreadable receipt as unknown and withdraws nothing', async () => { + await rig.workingSend() + const draftId = await queuedDraft('unknown delete') + const id = opId() + writeUnreadableReceipt(id, 'agentSession.queuedMessageDelete') + expect(await rig.deleteQueued(draftId, id)).toMatchObject(UNKNOWN) + expect(await rig.drafts()).toContainEqual( + expect.objectContaining({ messageId: draftId, state: 'waiting' }) + ) + }) + + it('refuses the same id naming another card and keeps that card', async () => { + await rig.workingSend() + const first = await queuedDraft('first card') + const second = await queuedDraft('second card') + const id = opId() + await rig.deleteQueued(first, id) + expect(await rig.deleteQueued(second, id)).toMatchObject(CONFLICT) + expect(await rig.drafts()).toContainEqual( + expect.objectContaining({ messageId: second, state: 'waiting' }) + ) + }) + + it('answers unknown when a no-op cannot be recorded, and its retry never withdraws a card that arrived since', async () => { + await rig.workingSend() + const lateCard = opId() + const id = opId() + const restore = failReceiptWrites() + expect(await rig.deleteQueued(lateCard, id)).toMatchObject(UNKNOWN) + restore() + // The card the Delete named lands after it: a send whose id is that card's. + const body = hostTestMessage('arrives late') + const fields = { body: agentSessionMessagePayload(body), delivery: 'queue-if-active' as const } + expect( + await rig.host.send(CALLER, { + envelope: rig.envelope(fields, 'agentSession.send', lateCard), + ...fields, + body, + userSend: true + }) + ).toMatchObject({ ok: true, value: { queued: { messageId: lateCard } } }) + expect(await rig.deleteQueued(lateCard, id)).toMatchObject({ + ok: true, + replayed: true, + value: { deleted: false, messageId: lateCard, disposition: 'missing' } + }) + expect(await rig.drafts()).toContainEqual( + expect.objectContaining({ messageId: lateCard, state: 'waiting' }) + ) + }) + + it('answers a retry after a rewind from its receipt, withdrawing nothing again', async () => { + await rig.workingSend() + const draftId = await queuedDraft('deleted then rewound') + const id = opId() + await rig.deleteQueued(draftId, id) + await rewindConversation() + expect(await rig.deleteQueued(draftId, id)).toMatchObject({ + ok: true, + replayed: true, + value: { deleted: true, messageId: draftId } + }) + }) +}) + +describe('Resume', () => { + it('lifts the pause once under an old id and never lifts a later pause under it', async () => { + await rig.workingSend() + await queuedDraft('held by stop') + await queuedDraft('held by the second stop') + await rig.stop() + const id = opId(OLD) + expect(await rig.resume(id)).toMatchObject({ + ok: true, + replayed: false, + value: { resumed: true } + }) + expect(receipt(id)).toMatchObject({ receipt: { result: { kind: 'journal-row' } } }) + await rig.stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.resume(id)).toMatchObject({ + ok: true, + replayed: true, + value: { resumed: false } + }) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it('records a no-op Resume and replays it', async () => { + const id = opId(FUTURE) + expect(await rig.resume(id)).toMatchObject({ + ok: true, + replayed: false, + value: { resumed: false } + }) + expect(receipt(id)).toMatchObject({ + receipt: { result: { kind: 'no-op', outcome: { kind: 'queue-resume', resumed: false } } } + }) + expect(await rig.resume(id)).toMatchObject({ + ok: true, + replayed: true, + value: { resumed: false } + }) + }) + + it('keeps the pause when its receipt cannot be saved', async () => { + await rig.workingSend() + await queuedDraft('still held') + await rig.stop() + const restore = failReceiptWrites() + await expect(rig.resume(opId())).rejects.toThrow('receipt unavailable') + restore() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it('answers concurrent copies of one id with one Resume', async () => { + await pausedQueue() + const id = opId() + const [first, second] = await Promise.all([rig.resume(id), rig.resume(id)]) + expect(first).toMatchObject({ ok: true, replayed: false, value: { resumed: true } }) + expect(second).toMatchObject({ ok: true, replayed: true, value: { resumed: false } }) + expect(await rig.queuePause()).toBeNull() + }) + + it('answers unknown when a no-op cannot be recorded, and its retry never lifts a later pause', async () => { + const id = opId() + let restore = failReceiptWrites() + expect(await rig.resume(id)).toMatchObject(UNKNOWN) + expect(receipt(id)).toEqual({ verdict: 'absent' }) + restore() + await pausedQueue() + // Still unrecordable: still unknown, and the Stop's pause stands. + restore = failReceiptWrites() + expect(await rig.resume(id)).toMatchObject(UNKNOWN) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + restore() + expect(await rig.resume(id)).toMatchObject({ + ok: true, + replayed: true, + value: { resumed: false } + }) + expect(receipt(id)).toMatchObject({ + receipt: { result: { kind: 'no-op', outcome: { kind: 'queue-resume', resumed: false } } } + }) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it('answers an unreadable receipt as unknown and lifts nothing', async () => { + await pausedQueue() + const id = opId() + writeUnreadableReceipt(id, 'agentSession.queuedMessagesResume') + expect(await rig.resume(id)).toMatchObject(UNKNOWN) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) + + it('answers a retry after a rewind from its receipt and never lifts the pause a later Stop set', async () => { + await pausedQueue() + const id = opId() + expect(await rig.resume(id)).toMatchObject({ ok: true, value: { resumed: true } }) + await rewindConversation() + await rig.stop() + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(await rig.resume(id)).toMatchObject({ + ok: true, + replayed: true, + value: { resumed: false } + }) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + }) +}) + +describe.each([ + ['Delete', (id: string) => rig.deleteQueued('some-card', id)], + ['Resume', (id: string) => rig.resume(id)] +])('%s refused before acceptance', (_action, act) => { + it('records nothing', async () => { + const id = 'not-an-operation-id' + expect(await act(id)).toMatchObject({ + ok: false, + refusal: { code: 'agent_session_operation_invalid' } + }) + expect(receipt(id)).toEqual({ verdict: 'absent' }) + expect(ledgerRow(id)).toBeUndefined() + }) +}) + +describe('an id reused across commands', () => { + const conflict = CONFLICT + + it('refuses a Stop reusing a Delete id, through the compatibility ledger row', async () => { + await rig.workingSend() + const draftId = await queuedDraft('delete then stop') + const id = opId() + await rig.deleteQueued(draftId, id) + expect(await rig.stop(id)).toMatchObject(conflict) + }) + + it('refuses a Delete or Resume reusing a Stop id, committing nothing', async () => { + await rig.workingSend() + const draftId = await queuedDraft('stop then delete') + const id = opId() + await rig.stop(id) + expect(await rig.deleteQueued(draftId, id)).toMatchObject(conflict) + expect(await rig.resume(id)).toMatchObject(conflict) + expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) + expect(receipt(id)).toEqual({ verdict: 'absent' }) + }) + + it('refuses a Send now reusing a Delete id, and the reverse', async () => { + await rig.workingSend() + const first = await queuedDraft('first') + const second = await queuedDraft('second') + const deleteId = opId() + await rig.deleteQueued(first, deleteId) + expect(await rig.sendNow(second, deleteId)).toMatchObject(conflict) + const sendId = opId() + await rig.sendNow(second, sendId) + expect(await rig.deleteQueued(second, sendId)).toMatchObject(conflict) + }) +}) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 4c8c2ab2e6c8..d2f1d3fb2b46 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -1,8 +1,7 @@ -// `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete`, and -// mutations of the conversation's queued drafts. Settling -// operations stamp op-scoped tombstone receipts, so a lost acknowledgement -// replays from the rows themselves — never from the operation ledger, which -// records only that an operation happened. No mutation returns draft text: +// `agentSession.queuedMessageSend` / `agentSession.queuedMessageDelete` / Resume: mutations of +// the conversation's queued drafts. Each action's command receipt commits with the row it +// changes, so a lost acknowledgement replays from that receipt and the rows it points at, never +// from the operation ledger. No mutation returns draft text: // the published list is the one authority a client renders. import type { AgentJournalSubmission } from '../../../shared/agent-session-journal-types' @@ -21,7 +20,11 @@ import { journalOpenRefusal } from '../agent-session-journal/journal-open-failure' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' -import type { MutationPlan } from './structured-agent-session-mutation-plans' +import { + journalRowReceiptResult, + type MutationPlan +} from './structured-agent-session-mutation-plans' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import { structuredQueueHold } from './structured-agent-session-queued-messages' import { resumeStructuredQueue, @@ -62,12 +65,16 @@ export async function withdrawQueuedMessagesForOperation( messageIds: readonly string[] callerKey: string operationId: string - } + }, + receipt?: JournalOperationReceipt ): Promise { - return journal.queuedMessages.withdraw({ - messageIds: input.messageIds, - settledByOp: agentSessionOperationKey(input.callerKey, input.operationId) - }) + return journal.queuedMessages.withdraw( + { + messageIds: input.messageIds, + settledByOp: agentSessionOperationKey(input.callerKey, input.operationId) + }, + receipt + ) } /** Draft actions run like any mutation: admitted on the session's lane, the @@ -119,13 +126,12 @@ export function sendQueuedStructuredAgentMessage( method: 'agentSession.queuedMessageSend', fields: { messageId }, conversationWrite: true, + // Its own submission, or the card another hand-off already turned into one. + commandReceipt: { + result: (row) => journalRowReceiptResult(row, 'submission'), + unwritten: () => ({ kind: 'queued-draft', messageId }) + }, run: async (ctx): Promise> => { - // A rerun of this operation after it consumed the card (its answer never - // settled): answer with the submission it made, never append it again. - const consumedHere = submissionFor(ctx, operationId) - if (consumedHere?.queuedMessageId === messageId) { - return { ok: true, value: { clientMessageId: operationId, submission: consumedHere } } - } // The one queue gate; Send-now's override set is exactly `working` (plus // FIFO order and the stored hold, which the consume below clears). const record = context.deps.store.getRecord(ctx.sessionId) @@ -172,7 +178,8 @@ export function sendQueuedStructuredAgentMessage( expect: row.state, settledByOp: agentSessionOperationKey(ctx.resolvedBy, operationId), hostInstance: structuredAgentSessionHostInstance() - } + }, + ctx.operationReceipt ) } catch (error) { if (error instanceof QueuedMessageNotConsumableError) { @@ -184,19 +191,17 @@ export function sendQueuedStructuredAgentMessage( if (!submission) { throw new Error('agent_session_submission_lost') } - context.wakeDelivery(ctx.sessionId) return { ok: true, value: { clientMessageId: submissionId, submission } } }, - replay: (ctx) => { - const opKey = agentSessionOperationKey(ctx.resolvedBy, operationId) - const row = ctx.journal.queuedMessages - .receipts(opKey) - .find((receipt) => receipt.messageId === messageId) - if (!row || row.state !== 'dispatched') { - return null - } - const submission = row.consumedAs === null ? undefined : submissionFor(ctx, row.consumedAs) - return submission ? { clientMessageId: submission.clientMessageId, submission } : null + replay: (ctx, _outcome, receipt) => { + // An acknowledged hand-off answers with the card's submission; this one's, with its own. + const card = + receipt?.kind === 'queued-draft' ? ctx.journal.queuedMessages.get(messageId) : null + const submissionId = card ? card.consumedAs : operationId + const submission = submissionId === null ? undefined : submissionFor(ctx, submissionId) + return submission?.queuedMessageId === messageId + ? { clientMessageId: submission.clientMessageId, submission } + : null } } return mutateQueued(context, caller, params.envelope, plan) @@ -216,6 +221,17 @@ export function deleteQueuedStructuredAgentMessage( method: 'agentSession.queuedMessageDelete', fields: { messageId }, conversationWrite: true, + // The withdrawn card, or a no-op's answer when there was nothing left to withdraw. + commandReceipt: { + result: () => ({ kind: 'queued-draft', messageId }), + unwritten: (value) => + value.deleted + ? null + : { + kind: 'no-op', + outcome: { kind: 'queue-delete', messageId, disposition: value.disposition } + } + }, run: async (ctx): Promise> => { const row = ctx.journal.queuedMessages.get(messageId) if (!row) { @@ -230,21 +246,26 @@ export function deleteQueuedStructuredAgentMessage( // The withdrawal notifies through the journal's commit listener, which // also re-derives the drain — deleting a returned card can unblock the // drafts behind it. - const withdrawn = await withdrawQueuedMessagesForOperation(ctx.journal, { - sessionId: ctx.sessionId, - messageIds: [messageId], - callerKey: ctx.resolvedBy, - operationId - }) + const withdrawn = await withdrawQueuedMessagesForOperation( + ctx.journal, + { + sessionId: ctx.sessionId, + messageIds: [messageId], + callerKey: ctx.resolvedBy, + operationId + }, + ctx.operationReceipt + ) return withdrawn.length > 0 ? { ok: true, value: { deleted: true, messageId } } : { ok: true, value: { deleted: false, messageId, disposition: 'withdrawn' } } }, - replay: (ctx) => { - const replayed = ctx.journal.queuedMessages - .receipts(agentSessionOperationKey(ctx.resolvedBy, operationId)) - .some((row) => row.messageId === messageId && row.state === 'withdrawn') - return replayed ? { deleted: true, messageId } : null + // The receipt alone answers, so a pruned tombstone cannot lose it. + replay: (_ctx, _outcome, receipt) => { + if (receipt?.kind === 'no-op' && receipt.outcome.kind === 'queue-delete') { + return { deleted: false, messageId, disposition: receipt.outcome.disposition } + } + return receipt?.kind === 'queued-draft' ? { deleted: true, messageId } : null } } return mutateQueued(context, caller, params.envelope, plan) @@ -262,11 +283,17 @@ export function resumeStructuredAgentQueue( method: 'agentSession.queuedMessagesResume', fields: {}, conversationWrite: true, + commandReceipt: { + result: (row) => journalRowReceiptResult(row, 'tombstone'), + unwritten: () => ({ kind: 'no-op', outcome: { kind: 'queue-resume', resumed: false } }) + }, // The Resume row notifies through the journal's commit listener, which publishes the // lifted pause and wakes the drain. run: async (ctx) => ({ ok: true, - value: { resumed: await resumeStructuredQueue(ctx.journal, ctx.fence) } + value: { + resumed: await resumeStructuredQueue(ctx.journal, ctx.fence, ctx.operationReceipt) + } }), // Like Stop's replay: the Resume already ran, so this one lifts nothing. replay: () => ({ resumed: false }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts index e59a56a7c795..9dd963bb43bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-pause.ts @@ -5,6 +5,7 @@ import { randomUUID } from 'node:crypto' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import type { DerivedQueuePause } from '../agent-session-journal/queued-message-pause' +import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { StructuredAgentSessionLogger } from './structured-agent-session-logger' /** A per-process id, minted once per host process like the runtime's own @@ -56,11 +57,12 @@ export async function markStructuredQueueReopen( /** Resume: a journal row that ends every pause. Returns whether the queue was paused. */ export async function resumeStructuredQueue( journal: Pick, - fence: number + fence: number, + receipt?: JournalOperationReceipt ): Promise { if (structuredQueuePauses(journal).length === 0) { return false } - await journal.appendQueueResume(fence) + await journal.appendQueueResume(fence, receipt) return true } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 3a2b9a205f28..60f219dafe5c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -23,6 +23,8 @@ import { AgentSessionPromptAnswerRejectedError, AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' +import type { CommandReceiptResult } from '../agent-session-journal/command-receipt-schema' +import { readJournalItemRevision } from '../agent-session-journal/journal-item-revision-read' import { settledPrompt, validatePendingPrompt } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -132,7 +134,9 @@ export async function performPrompt( identity, { ...prompt, resolution }, // A revision: the prompt keeps the turn it was raised in. - { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() } + { fence: ctx.fence, turnScope: ctx.journal.liveTurnScope() }, + // The answer's identity commits here, before the adapter tells the provider. + ctx.operationReceipt ) } }) @@ -167,3 +171,25 @@ export async function performPrompt( value: { itemId: appended.itemId, revision: appended.revision, resolution } } } + +/** The resolution an answer was accepted at, read where its receipt points, never the prompt's + * latest revision; null once that epoch or row is gone, so the answer is spent, never run again. */ +export function acceptedPromptAnswer( + ctx: Pick, + receipt: CommandReceiptResult | undefined +): AgentSessionPromptResult | null { + const at = + receipt?.kind === 'journal-row' + ? { epoch: receipt.epoch, sequence: receipt.sequence } + : receipt?.kind === 'item-revision' + ? receipt + : null + const written = at ? readJournalItemRevision(ctx.journal, at) : null + const body = written?.body + if (!written || (body?.kind !== 'approval' && body?.kind !== 'question')) { + return null + } + return body.resolution.state === 'pending' + ? null + : { itemId: written.itemId, revision: written.revision, resolution: body.resolution } +} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts index b9ca710a1fd2..cb3d87e72e95 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns.ts @@ -68,7 +68,7 @@ export type AgentSessionTurnContext = { publish: () => void /** Who a Stop's refusal row names. */ failureTextContext?: AgentSessionFailureWordsContext - /** The operation's success, committed with the row that accepts it (`settlesWithWrite` or `acceptsWithCommandReceipt`). */ + /** The operation's success, committed with the row that accepts it (`settlesWithWrite` or `commandReceipt`). */ operationReceipt?: JournalOperationReceipt now: () => number } diff --git a/src/main/runtime/agent-session-record-store.ts b/src/main/runtime/agent-session-record-store.ts index 986dd2e1df81..0755cead87e5 100644 --- a/src/main/runtime/agent-session-record-store.ts +++ b/src/main/runtime/agent-session-record-store.ts @@ -69,6 +69,7 @@ import { showAgentSessionTabs } from './agent-session-tab-table' import type { JournalHostDatabase } from '../native-chat/agent-session-journal/journal-host-database' +import type { JournalOperationReceipt } from '../native-chat/agent-session-journal/journal-row-writer' import { agentSessionOperationOutcomeReceipt } from './agent-session-operation-receipt' import { loadAgentSessionStoreRows } from './agent-session-record-rows' import { AgentSessionStoreTransactions } from './agent-session-store-transactions' @@ -311,6 +312,9 @@ export class AgentSessionRecordStore { operationOutcomeReceipt = (args: Parameters[1]) => agentSessionOperationOutcomeReceipt(this.transactions, args) + commitOperationReceipt = (receipt: JournalOperationReceipt) => + this.transactions.commitReceipt(receipt) + readCommandReceipt = (...args: Parameters) => this.transactions.readCommandReceipt(...args) diff --git a/src/main/runtime/agent-session-store-transactions.ts b/src/main/runtime/agent-session-store-transactions.ts index 5cc0713a36bf..c2c56f9af8e2 100644 --- a/src/main/runtime/agent-session-store-transactions.ts +++ b/src/main/runtime/agent-session-store-transactions.ts @@ -164,6 +164,16 @@ export class AgentSessionStoreTransactions { } } + /** A receipt no journal write carries (a command that changed nothing), in its own transaction. */ + commitReceipt(receipt: JournalOperationReceipt): Promise { + const run = this.queue.then(() => { + this.journalDatabase.transaction((db) => receipt.write(db)) + receipt.committed() + }) + this.queue = run.catch(() => {}) + return run + } + private commit(apply: (draft: AgentSessionStoreState) => T, inMemoryWhenReadOnly: boolean): T { const readOnly = this.journalDatabase.readOnly if (readOnly && !inMemoryWhenReadOnly) { diff --git a/src/shared/agent-session-mutation-envelope.ts b/src/shared/agent-session-mutation-envelope.ts index a3acf31af4f1..dc783baa0a1a 100644 --- a/src/shared/agent-session-mutation-envelope.ts +++ b/src/shared/agent-session-mutation-envelope.ts @@ -132,7 +132,9 @@ export function agentSessionLedgerRefusal( /** Why the single admission oracle said no, mapped to what the client can do * about it. The predicate itself is never re-implemented here. */ -function refuseUnlessWriterAdmitted(lease: AgentSessionLease): AgentSessionWireRefusal | null { +export function refuseUnlessWriterAdmitted( + lease: AgentSessionLease +): AgentSessionWireRefusal | null { if (agentSessionLeaseAdmitsWriter(lease)) { return null } From be4746eb4dafcca802644950ca70e7a49f5d7f62 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:02:09 -0700 Subject: [PATCH 09/11] fix(native-chat): replay a queued action from what its receipt recorded Send Now's acknowledgement points at the submission it handed off; a question answer's receipt keeps the accepted resolution; a no-op that cannot save its receipt answers outcome unknown and holds nothing in memory; classified refusals pass through. --- .../command-receipt-schema.ts | 23 +++- .../journal-item-revision-read.ts | 56 -------- ...tructured-agent-session-command-receipt.ts | 41 +----- ...structured-agent-session-mutation-plans.ts | 19 ++- ...ent-session-prompt-answer-receipts.test.ts | 93 ++++++++++--- ...ent-session-queued-action-receipts.test.ts | 123 ++++++++++-------- ...ructured-agent-session-queued-mutations.ts | 27 +++- .../structured-agent-session-turns-prompt.ts | 60 ++++++--- 8 files changed, 235 insertions(+), 207 deletions(-) delete mode 100644 src/main/native-chat/agent-session-journal/journal-item-revision-read.ts diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts index 28b9c33a4f25..24f5c86dfc66 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts @@ -29,12 +29,27 @@ export const commandReceiptResultSchema = z.discriminatedUnion('kind', [ kind: z.literal('queued-draft'), messageId: z.string().min(1) }), - /** An earlier operation's revision this command acknowledged instead of writing its own. */ + /** An accepted prompt answer, kept whole: no rewind or later revision changes its replay. Its + * values are the resolution the prompt's row holds, bounded as that row is. */ z.strictObject({ - kind: z.literal('item-revision'), - epoch: z.string().min(1), + kind: z.literal('prompt-answer'), itemId: z.string().min(1), - revision: z.int().nonnegative() + revision: z.int().positive(), + resolution: z.strictObject({ + state: z.literal('resolved'), + selectedOptionId: z.string().nullable(), + answers: z + .array( + z.strictObject({ + questionId: z.string(), + optionIds: z.array(z.string()), + other: z.string().optional() + }) + ) + .optional(), + resolvedBy: z.string().nullable(), + resolvedAt: z.number().nullable() + }) }), z.strictObject({ kind: z.literal('no-op'), diff --git a/src/main/native-chat/agent-session-journal/journal-item-revision-read.ts b/src/main/native-chat/agent-session-journal/journal-item-revision-read.ts deleted file mode 100644 index 2ef5779ad980..000000000000 --- a/src/main/native-chat/agent-session-journal/journal-item-revision-read.ts +++ /dev/null @@ -1,56 +0,0 @@ -// An item as one write left it, read back from the epoch that wrote it: a replay answers with the -// revision a command was accepted at, never the item's latest fold. A retired epoch's rows are -// deleted with it, so a pointer into one reads nothing. - -import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types' -import type { AgentSessionJournal } from './journal-store' - -export type JournalItemRevisionPointer = - | { epoch: string; sequence: number } - | { epoch: string; itemId: string; revision: number } - -export type JournalItemRevision = { itemId: string; revision: number; body: AgentJournalItemBody } - -type RevisionSource = Pick - -const SCAN_PAGE = 256 - -export function readJournalItemRevision( - journal: RevisionSource, - at: JournalItemRevisionPointer -): JournalItemRevision | null { - if (at.epoch !== journal.epoch) { - return null - } - if ('sequence' in at) { - const read = journal.readSince({ epoch: at.epoch, sequence: at.sequence - 1 }, 1) - const row = read.ok ? read.rows[0] : undefined - return row?.kind === 'item' && row.seq === at.sequence - ? { itemId: row.itemId, revision: row.revision, body: row.body } - : null - } - const target = journal.canonicalItemId(at.itemId) - const folded = journal.item(target) - if (folded?.revision === at.revision) { - return { itemId: at.itemId, revision: at.revision, body: folded.body } - } - // A later revision replaced the fold's body; only the epoch's rows still hold this one. - for (let after = 0; ;) { - const read = journal.readSince({ epoch: at.epoch, sequence: after }, SCAN_PAGE) - const rows = read.ok ? read.rows : [] - const row = rows.find( - (entry) => - entry.kind === 'item' && - entry.revision === at.revision && - journal.canonicalItemId(entry.itemId) === target - ) - if (row?.kind === 'item') { - return { itemId: at.itemId, revision: at.revision, body: row.body } - } - const last = rows.at(-1) - if (!last || rows.length < SCAN_PAGE) { - return null - } - after = last.seq - } -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts index f4f331e5835b..e011265fafb1 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-command-receipt.ts @@ -40,11 +40,6 @@ export async function runCommandReceiptMutation( } ): Promise | { committedReceipt: CommandReceipt }> { const { envelope, plan, context, commandReceipt, wakeDelivery } = input - const unrecorded = await commitUnrecordedNoOp(input) - if (unrecorded) { - // A differing fingerprint is answered as the conflict it is, from the uncommitted receipt. - return unrecorded === 'unknown' ? unknownOutcome(input) : { committedReceipt: unrecorded } - } let acceptedReceipt: CommandReceipt | undefined let submissionWritten = false const receipt = acceptanceReceipt(input, (row) => { @@ -154,18 +149,9 @@ function unknownOutcome(input: CommandReceiptIdentity): TurnOutcome { } } -/** No-ops answered unknown because their receipt did not commit, per store and receipt key. */ -const unrecordedNoOps = new WeakMap>() - -function unrecordedKey({ operationCallerKey, plan, envelope }: CommandReceiptIdentity): string { - const scope = commandReceiptScope(operationCallerKey, plan.operationIdScope) - return `${scope.kind === 'caller' ? scope.callerKey : ''}\u0000${envelope.clientOperationId}` -} - -/** A no-op is answered only once its receipt commits, alone. An identity verdict (duplicate, - * conflict, unreadable) throws for admission to answer; any other failure is logged, and the - * decision is held so a retry in this process commits it rather than deciding again on newer - * work. A restart forgets it. */ +/** A no-op is answered only once its receipt commits, alone. An identity verdict or a classified + * refusal throws for admission to answer; an unclassified storage failure is logged and answered + * unknown, with nothing recorded, so a retry decides afresh. */ async function commitNoOpReceipt( input: CommandReceiptIdentity, receipt: CommandReceipt @@ -174,11 +160,7 @@ async function commitNoOpReceipt( await input.store.commitOperationReceipt(acceptanceReceipt(input, () => receipt)) return true } catch (error) { - if ( - error instanceof CommandReceiptExistsError || - (isAgentSessionRefusalError(error) && - error.refusal.code === 'agent_session_operation_conflict') - ) { + if (error instanceof CommandReceiptExistsError || isAgentSessionRefusalError(error)) { throw error } input.context.logger.warn( @@ -190,21 +172,6 @@ async function commitNoOpReceipt( error } ) - const held = unrecordedNoOps.get(input.store) ?? new Map() - unrecordedNoOps.set(input.store, held.set(unrecordedKey(input), receipt)) return false } } - -/** The no-op this id was answered unknown for, committed now; null when none is held. */ -async function commitUnrecordedNoOp( - input: CommandReceiptIdentity -): Promise { - const key = unrecordedKey(input) - const receipt = unrecordedNoOps.get(input.store)?.get(key) - if (!receipt || receipt.fingerprint !== input.fingerprint) { - return receipt ?? null - } - unrecordedNoOps.get(input.store)?.delete(key) - return (await commitNoOpReceipt(input, receipt)) ? receipt : 'unknown' -} diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index ecc5fa9d16ef..d323856e55ee 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -1,8 +1,8 @@ // One plan per mutating method: what it fingerprints, what it does, and how its // answer is rebuilt on a replay. // -// A plan with `commandReceipt` proves acceptance by that receipt, the rest by their ledger row. The -// journal projects the answer: a prompt's from the revision it was accepted at, others' as they stand. +// A plan with `commandReceipt` proves acceptance by that receipt, the rest by their ledger row. A +// prompt answer replays from its receipt alone; the journal projects the others' current answer. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { @@ -40,6 +40,8 @@ import { } from './structured-agent-session-turns' import { acceptedPromptAnswer, + promptAnswerReceipt, + promptRowAnswer, type AgentSessionPromptRequest } from './structured-agent-session-turns-prompt' import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' @@ -320,18 +322,13 @@ export function promptPlan( optionId: params.optionId, answers: params.answers }, - // The resolved revision carries it; an answer the prompt already holds points at that revision. + // The resolved revision's answer, or the answer the prompt already held, kept in the receipt. commandReceipt: { - result: (row) => journalRowReceiptResult(row, 'item'), - unwritten: (value, ctx) => ({ - kind: 'item-revision', - epoch: ctx.journal.epoch, - itemId: value.itemId, - revision: value.revision - }) + result: (row) => promptAnswerReceipt(promptRowAnswer(row)), + unwritten: (value) => promptAnswerReceipt(value) }, run: (ctx) => performPrompt(ctx, params), - replay: (ctx, _outcome, receipt) => acceptedPromptAnswer(ctx, receipt) + replay: (_ctx, _outcome, receipt) => acceptedPromptAnswer(receipt) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts index 30788ee0b516..6b596eff2d53 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts @@ -8,6 +8,7 @@ import { AGENT_JOURNAL_THREAD_SCOPE, type AgentJournalItemBody } from '../../../shared/agent-session-journal-types' +import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' @@ -32,10 +33,6 @@ const CONFLICT = { refusal: { code: 'agent_session_operation_conflict', details: { reason: 'operationIdReused' } } } const UNKNOWN = { ok: false, refusal: { code: 'agent_session_operation_unknown' } } -const SPENT = { - ok: false, - refusal: { code: 'agent_session_operation_unknown', details: { reason: 'resultLost' } } -} /** The provider identity `seedApproval` raises its prompt under. */ const PROMPT = { provider: 'codex' as const, threadId: THREAD, turnId: 'turn-1', ordinal: 99 } @@ -146,7 +143,7 @@ describe('a prompt answer', () => { }) expect(receipt(id)).toMatchObject({ verdict: 'readable', - receipt: { method: 'agentSession.respondTo:approval', result: { kind: 'journal-row' } } + receipt: { method: 'agentSession.respondTo:approval', result: { kind: 'prompt-answer' } } }) expect(answerPrompt).toHaveBeenCalledOnce() }) @@ -193,7 +190,12 @@ describe('a prompt answer', () => { }) expect(receipt(alias, other.callerKey)).toMatchObject({ receipt: { - result: { kind: 'item-revision', itemId: prompt.itemId, revision: prompt.revision + 1 } + result: { + kind: 'prompt-answer', + itemId: prompt.itemId, + revision: prompt.revision + 1, + resolution: { state: 'resolved', selectedOptionId: 'allow' } + } } }) expect(await host.respondToPrompt(other, answer(prompt, alias))).toMatchObject({ @@ -275,36 +277,87 @@ describe('a prompt answer', () => { ['its own answer', CALLER], ['the answer another id acknowledged', { callerKey: 'client-2' }] ])( - 'answers a retry after a rewind as spent, never answering again: %s', + 'replays the accepted answer after a rewind, never answering again: %s', async (_case, caller) => { const prompt = await seeded() const params = answer(prompt, opId()) if (caller !== CALLER) { await host.respondToPrompt(CALLER, answer(prompt, opId())) } - expect(await host.respondToPrompt(caller, params)).toMatchObject({ ok: true }) + const first = await host.respondToPrompt(caller, params) + expect(first).toMatchObject({ ok: true, replayed: false }) await rewindKeepingPrompt(prompt.itemId) - expect(await host.respondToPrompt(caller, params)).toMatchObject(SPENT) + expect(journal().item(prompt.itemId)?.revision).not.toBe(prompt.revision + 1) + expect(await host.respondToPrompt(caller, params)).toEqual({ + ...first, + replayed: true, + cursor: expect.anything() + }) expect(answerPrompt).toHaveBeenCalledOnce() } ) - it('answers unknown, never success, when the revision it points at cannot be recorded', async () => { + it('replays a grouped answer whole after a rewind, free text included', async () => { + await attach() + const question = { ...PROMPT, ordinal: 100 } + acquire.mock.calls.at(-1)?.[0].events?.appendItem( + question, + { + kind: 'question', + question: '2 grouped questions', + options: [], + questions: [ + { + id: 'q1', + question: 'Targets', + multiSelect: true, + options: [ + { id: 'web', label: 'Web' }, + { id: 'mobile', label: 'Mobile' } + ] + }, + { id: 'q2', question: 'Host', multiSelect: false, options: [], freeTextQuestionId: 'q2' } + ], + resolution: { state: 'pending', selectedOptionId: null, resolvedBy: null, resolvedAt: null } + }, + { turnScope: AGENT_JOURNAL_THREAD_SCOPE } + ) + await host.flushStreamedEvents(SESSION) + const itemId = agentJournalItemKey(question) + const answers = [ + { questionId: 'q1', optionIds: ['web', 'mobile'] }, + { questionId: 'q2', optionIds: [], other: 'SSH host' } + ] + const fields = { itemId, expectedRevision: journal().item(itemId)!.revision, answers } + const params = { + envelope: envelope('agentSession.respondTo:question', fields, { clientOperationId: opId() }), + kind: 'question' as const, + ...fields + } + const first = await host.respondToPrompt(CALLER, params) + expect(first).toMatchObject({ ok: true, value: { resolution: { answers } } }) + await journal().replaceEpochItems( + 'handle_forked', + store.getRecord(SESSION)!.lease.runtimeFence, + [] + ) + expect(await host.respondToPrompt(CALLER, params)).toEqual({ + ...first, + replayed: true, + cursor: expect.anything() + }) + expect(answerPrompt).toHaveBeenCalledOnce() + }) + + it('answers unknown, never success, when acknowledging an answer cannot be recorded', async () => { const prompt = await seeded() await host.respondToPrompt(CALLER, answer(prompt, opId())) const other = { callerKey: 'client-2' } - const params = answer(prompt, opId()) + const id = opId() const restore = failReceiptWrites() - expect(await host.respondToPrompt(other, params)).toMatchObject(UNKNOWN) - expect(await host.respondToPrompt(other, params)).toMatchObject(UNKNOWN) + expect(await host.respondToPrompt(other, answer(prompt, id))).toMatchObject(UNKNOWN) restore() - await reviseFromProvider(prompt.itemId) - // The retry acknowledges the revision the first attempt saw, not the provider's later one. - expect(await host.respondToPrompt(other, params)).toMatchObject({ - ok: true, - replayed: true, - value: { revision: prompt.revision + 1 } - }) + expect(receipt(id, other.callerKey)).toEqual({ verdict: 'absent' }) expect(answerPrompt).toHaveBeenCalledOnce() }) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts index ede2e3fa58d6..06d3bd164bc6 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-action-receipts.test.ts @@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../shared/agent-session-host-authority' -import { agentSessionMessagePayload } from '../../../shared/structured-agent-session-send-mutation' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' import { createQueuedMessageTestRig, @@ -13,8 +12,7 @@ import { } from './structured-agent-session-queued-message-rig.test-fixture' import { HOST_TEST_NOW as NOW, - HOST_TEST_SESSION as SESSION, - hostTestMessage + HOST_TEST_SESSION as SESSION } from './structured-agent-session-host-test-data' const CALLER_SCOPE = { kind: 'caller', callerKey: CALLER.callerKey } as const @@ -141,9 +139,7 @@ describe('Send now', () => { replayed: false, value: { clientMessageId: first } }) - expect(receipt(alias)).toMatchObject({ - receipt: { result: { kind: 'queued-draft', messageId: draftId } } - }) + expect(receipt(alias)).toMatchObject({ receipt: { result: { kind: 'journal-row' } } }) expect(await rig.sendNow(draftId, alias)).toMatchObject({ ok: true, replayed: true, @@ -151,6 +147,50 @@ describe('Send now', () => { }) }) + it('replays the hand-off it acknowledged, not a later one that took the card after it came back', async () => { + await rig.workingSend() + const draftId = await queuedDraft('card') + const first = opId() + expect(await rig.sendNow(draftId, first)).toMatchObject({ value: { clientMessageId: first } }) + const ack = opId() + expect(await rig.sendNow(draftId, ack)).toMatchObject({ + ok: true, + replayed: false, + value: { clientMessageId: first } + }) + // The provider refuses the first hand-off: the card comes back, and another Send takes it. + await rig.settleRejected(first, 'nope') + const later = opId() + expect(await rig.sendNow(draftId, later)).toMatchObject({ + ok: true, + value: { clientMessageId: later } + }) + expect(await rig.sendNow(draftId, ack)).toMatchObject({ + ok: true, + replayed: true, + value: { clientMessageId: first } + }) + }) + + it('replays its hand-off once the dispatched card is pruned', async () => { + await rig.workingSend() + const draftId = await queuedDraft('pruned card') + const first = opId() + await rig.sendNow(draftId, first) + const ack = opId() + await rig.sendNow(draftId, ack) + openTestJournalHostDatabase(rig.root) + .db.prepare('DELETE FROM queued_messages WHERE session_id = ? AND message_id = ?') + .run(SESSION, draftId) + for (const id of [first, ack]) { + expect(await rig.sendNow(draftId, id)).toMatchObject({ + ok: true, + replayed: true, + value: { clientMessageId: first } + }) + } + }) + it('refuses the same id naming another card', async () => { await rig.workingSend() const first = await queuedDraft('first card') @@ -191,7 +231,7 @@ describe('Send now', () => { expect(ledgerRow(id)).toBeUndefined() }) - it('answers unknown, never success, when the hand-off it points at cannot be recorded', async () => { + it('answers unknown, never success, when acknowledging a hand-off cannot be recorded', async () => { await rig.workingSend() const draftId = await queuedDraft('sent by another') const first = opId() @@ -199,16 +239,13 @@ describe('Send now', () => { const alias = opId() const restore = failReceiptWrites() expect(await rig.sendNow(draftId, alias)).toMatchObject(UNKNOWN) - expect(await rig.sendNow(draftId, alias)).toMatchObject(UNKNOWN) restore() - expect(await rig.sendNow(draftId, alias)).toMatchObject({ - ok: true, - replayed: true, - value: { clientMessageId: first } - }) - expect(receipt(alias)).toMatchObject({ - receipt: { result: { kind: 'queued-draft', messageId: draftId } } - }) + expect(receipt(alias)).toEqual({ verdict: 'absent' }) + expect(ledgerRow(alias)).toBeUndefined() + const handoffs = (await rig.host.journalSnapshot(SESSION)).submissions.filter( + (entry) => entry.queuedMessageId === draftId + ) + expect(handoffs).toHaveLength(1) }) it('answers an unreadable receipt as unknown and hands nothing off', async () => { @@ -337,32 +374,14 @@ describe('Delete', () => { ) }) - it('answers unknown when a no-op cannot be recorded, and its retry never withdraws a card that arrived since', async () => { + it('answers unknown, never success, when a no-op cannot be recorded, and records nothing', async () => { await rig.workingSend() - const lateCard = opId() const id = opId() const restore = failReceiptWrites() - expect(await rig.deleteQueued(lateCard, id)).toMatchObject(UNKNOWN) + expect(await rig.deleteQueued('no-such-card', id)).toMatchObject(UNKNOWN) restore() - // The card the Delete named lands after it: a send whose id is that card's. - const body = hostTestMessage('arrives late') - const fields = { body: agentSessionMessagePayload(body), delivery: 'queue-if-active' as const } - expect( - await rig.host.send(CALLER, { - envelope: rig.envelope(fields, 'agentSession.send', lateCard), - ...fields, - body, - userSend: true - }) - ).toMatchObject({ ok: true, value: { queued: { messageId: lateCard } } }) - expect(await rig.deleteQueued(lateCard, id)).toMatchObject({ - ok: true, - replayed: true, - value: { deleted: false, messageId: lateCard, disposition: 'missing' } - }) - expect(await rig.drafts()).toContainEqual( - expect.objectContaining({ messageId: lateCard, state: 'waiting' }) - ) + expect(receipt(id)).toEqual({ verdict: 'absent' }) + expect(ledgerRow(id)).toBeUndefined() }) it('answers a retry after a rewind from its receipt, withdrawing nothing again', async () => { @@ -438,27 +457,23 @@ describe('Resume', () => { expect(await rig.queuePause()).toBeNull() }) - it('answers unknown when a no-op cannot be recorded, and its retry never lifts a later pause', async () => { + it('answers unknown, never success, when a no-op cannot be recorded, and records nothing', async () => { const id = opId() - let restore = failReceiptWrites() - expect(await rig.resume(id)).toMatchObject(UNKNOWN) - expect(receipt(id)).toEqual({ verdict: 'absent' }) - restore() - await pausedQueue() - // Still unrecordable: still unknown, and the Stop's pause stands. - restore = failReceiptWrites() + const restore = failReceiptWrites() expect(await rig.resume(id)).toMatchObject(UNKNOWN) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) restore() + expect(receipt(id)).toEqual({ verdict: 'absent' }) + expect(ledgerRow(id)).toBeUndefined() + }) + + it("answers a no-op on a newer Orca's records with that refusal, not unknown", async () => { + await rig.workingSend() + const id = opId() + Object.defineProperty(openTestJournalHostDatabase(rig.root), 'readOnly', { value: true }) expect(await rig.resume(id)).toMatchObject({ - ok: true, - replayed: true, - value: { resumed: false } - }) - expect(receipt(id)).toMatchObject({ - receipt: { result: { kind: 'no-op', outcome: { kind: 'queue-resume', resumed: false } } } + ok: false, + refusal: { code: 'agent_session_journal_unreadable' } }) - expect(await rig.queuePause()).toEqual({ reason: 'stopped' }) }) it('answers an unreadable receipt as unknown and lifts nothing', async () => { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index d2f1d3fb2b46..294097c4ef0c 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -25,6 +25,7 @@ import { type MutationPlan } from './structured-agent-session-mutation-plans' import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' +import type { CommandReceiptResult } from '../agent-session-journal/command-receipt-schema' import { structuredQueueHold } from './structured-agent-session-queued-messages' import { resumeStructuredQueue, @@ -55,6 +56,17 @@ function submissionFor( return ctx.journal.submissions().find((entry) => entry.clientMessageId === clientMessageId) } +/** Where the journal wrote a hand-off's submission, so a replay answers with that one. */ +function submissionRowReceipt( + ctx: AgentSessionTurnContext, + submission: AgentJournalSubmission | undefined +): CommandReceiptResult { + if (submission?.submittedSequence === undefined) { + throw new Error('an acknowledged hand-off requires its submission row') + } + return { kind: 'journal-row', epoch: ctx.journal.epoch, sequence: submission.submittedSequence } +} + /** One transaction, stamped with the operation's caller-scoped key so a replay * answers "spent" from the receipts. Withdrawal retires any hold in the same * UPDATE, and the store's commit notification publishes the change. */ @@ -126,10 +138,11 @@ export function sendQueuedStructuredAgentMessage( method: 'agentSession.queuedMessageSend', fields: { messageId }, conversationWrite: true, - // Its own submission, or the card another hand-off already turned into one. + // Its own submission's row, or the row of the hand-off that already took the card. commandReceipt: { result: (row) => journalRowReceiptResult(row, 'submission'), - unwritten: () => ({ kind: 'queued-draft', messageId }) + unwritten: (value, ctx) => + submissionRowReceipt(ctx, 'submission' in value ? value.submission : undefined) }, run: async (ctx): Promise> => { // The one queue gate; Send-now's override set is exactly `working` (plus @@ -193,12 +206,12 @@ export function sendQueuedStructuredAgentMessage( } return { ok: true, value: { clientMessageId: submissionId, submission } } }, + // The hand-off the receipt points at, never whichever one the card holds now. replay: (ctx, _outcome, receipt) => { - // An acknowledged hand-off answers with the card's submission; this one's, with its own. - const card = - receipt?.kind === 'queued-draft' ? ctx.journal.queuedMessages.get(messageId) : null - const submissionId = card ? card.consumedAs : operationId - const submission = submissionId === null ? undefined : submissionFor(ctx, submissionId) + const submission = + receipt?.kind === 'journal-row' && receipt.epoch === ctx.journal.epoch + ? ctx.journal.submissions().find((entry) => entry.submittedSequence === receipt.sequence) + : undefined return submission?.queuedMessageId === messageId ? { clientMessageId: submission.clientMessageId, submission } : null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts index 60f219dafe5c..a7bbf523c3ce 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-turns-prompt.ts @@ -24,7 +24,7 @@ import { AgentSessionPromptUnavailableError } from './structured-agent-session-adapter' import type { CommandReceiptResult } from '../agent-session-journal/command-receipt-schema' -import { readJournalItemRevision } from '../agent-session-journal/journal-item-revision-read' +import type { JournalRow } from '../agent-session-journal/journal-row-schema' import { settledPrompt, validatePendingPrompt } from './structured-agent-session-prompt-state' import type { AgentSessionTurnContext, TurnOutcome } from './structured-agent-session-turns' @@ -172,24 +172,48 @@ export async function performPrompt( } } -/** The resolution an answer was accepted at, read where its receipt points, never the prompt's - * latest revision; null once that epoch or row is gone, so the answer is spent, never run again. */ +/** The answer a resolved prompt row carries, the one its writer was accepted with. */ +export function promptRowAnswer(row: JournalRow | undefined): AgentSessionPromptResult { + const body = row?.kind === 'item' ? row.body : undefined + if (row?.kind !== 'item' || (body?.kind !== 'approval' && body?.kind !== 'question')) { + throw new Error('an accepted prompt answer requires its prompt row') + } + return { itemId: row.itemId, revision: row.revision, resolution: body.resolution } +} + +/** What a receipt keeps of an accepted answer: everything its replay says, so the journal's + * later revisions or a rewind never change it. */ +export function promptAnswerReceipt(answer: AgentSessionPromptResult): CommandReceiptResult { + const { state, selectedOptionId, answers, resolvedBy, resolvedAt } = answer.resolution + if (state !== 'resolved') { + throw new Error('an accepted prompt answer is resolved') + } + return { + kind: 'prompt-answer', + itemId: answer.itemId, + revision: answer.revision, + resolution: { + state, + selectedOptionId, + ...(answers + ? { + answers: answers.map(({ questionId, optionIds, other }) => ({ + questionId, + optionIds, + ...(other === undefined ? {} : { other }) + })) + } + : {}), + resolvedBy, + resolvedAt + } + } +} + export function acceptedPromptAnswer( - ctx: Pick, receipt: CommandReceiptResult | undefined ): AgentSessionPromptResult | null { - const at = - receipt?.kind === 'journal-row' - ? { epoch: receipt.epoch, sequence: receipt.sequence } - : receipt?.kind === 'item-revision' - ? receipt - : null - const written = at ? readJournalItemRevision(ctx.journal, at) : null - const body = written?.body - if (!written || (body?.kind !== 'approval' && body?.kind !== 'question')) { - return null - } - return body.resolution.state === 'pending' - ? null - : { itemId: written.itemId, revision: written.revision, resolution: body.resolution } + return receipt?.kind === 'prompt-answer' + ? { itemId: receipt.itemId, revision: receipt.revision, resolution: receipt.resolution } + : null } From a7c2e9c526f4d0ba8f4d2570016cee89e9141c0b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:05:16 -0700 Subject: [PATCH 10/11] docs(native-chat): receipt comments say what a replay answers from --- .../agent-session-journal/command-receipt-schema.ts | 2 +- .../structured-agent-session-mutation-plans.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts index 24f5c86dfc66..e61de46588e0 100644 --- a/src/main/native-chat/agent-session-journal/command-receipt-schema.ts +++ b/src/main/native-chat/agent-session-journal/command-receipt-schema.ts @@ -18,7 +18,7 @@ export function commandReceiptScope( return operationIdScope === 'global' ? { kind: 'global' } : { kind: 'caller', callerKey } } -/** The pointer locates the result; if its row is gone, the command stays spent: read what remains, else unknown, never rerun. */ +/** What a replay answers from: a pointer (if its row is gone the command stays spent: read what remains, else unknown, never rerun), or the outcome itself. */ export const commandReceiptResultSchema = z.discriminatedUnion('kind', [ z.strictObject({ kind: z.literal('journal-row'), diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index d323856e55ee..47d5509f8db5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -2,7 +2,8 @@ // answer is rebuilt on a replay. // // A plan with `commandReceipt` proves acceptance by that receipt, the rest by their ledger row. A -// prompt answer replays from its receipt alone; the journal projects the others' current answer. +// prompt answer's replay answers from the resolution its receipt keeps; the journal projects the +// others' current answer. import type { AgentJournalMessageItem } from '../../../shared/agent-session-journal-types' import { From fcea398de81d105cbdd2ae684455df59e08748e7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:22:57 -0700 Subject: [PATCH 11/11] fix(native-chat): refuse a prompt answer on a newer Orca's journal with the update words A prompt answer now runs through the same newer-Orca refusal the queued-message actions use, so a read-only journal answers agent_session_journal_unreadable instead of throwing journal_read_only. --- .../journal-open-failure.ts | 14 ++++++++++++++ ...structured-agent-session-mutation-plans.ts | 3 ++- ...ent-session-prompt-answer-receipts.test.ts | 16 +++++++++++++++- ...ructured-agent-session-queued-mutations.ts | 19 +------------------ 4 files changed, 32 insertions(+), 20 deletions(-) diff --git a/src/main/native-chat/agent-session-journal/journal-open-failure.ts b/src/main/native-chat/agent-session-journal/journal-open-failure.ts index 73349ab09391..69ab0973e75d 100644 --- a/src/main/native-chat/agent-session-journal/journal-open-failure.ts +++ b/src/main/native-chat/agent-session-journal/journal-open-failure.ts @@ -115,6 +115,20 @@ export function journalOpenRefusal(error: unknown): AgentSessionWireRefusal { return refuse('agent_session_journal_unreadable', { reason }, message) } +/** A newer Orca's journal refuses a write with the words a send gets there. */ +export async function refusingNewerOrcaJournal( + run: Promise +): Promise { + try { + return await run + } catch (error) { + if (isJournalWrittenByNewerOrca(error)) { + return { ok: false, refusal: journalOpenRefusal(error) } + } + throw error + } +} + /** The same refusal, thrown: for a host that could not open the journal at all. It keeps what * failed as its cause, for the log. */ export function journalOpenRefusalError(error: unknown): AgentSessionRefusalError { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts index 47d5509f8db5..04688a8c491a 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-mutation-plans.ts @@ -49,6 +49,7 @@ import { queuedSendAnswer } from './structured-agent-session-queued-send-answer' import type { JournalOperationReceipt } from '../agent-session-journal/journal-row-writer' import type { CommandReceiptResult } from '../agent-session-journal/command-receipt-schema' import type { JournalRow } from '../agent-session-journal/journal-row-schema' +import { refusingNewerOrcaJournal } from '../agent-session-journal/journal-open-failure' export type MutationPlan = { method: string @@ -328,7 +329,7 @@ export function promptPlan( result: (row) => promptAnswerReceipt(promptRowAnswer(row)), unwritten: (value) => promptAnswerReceipt(value) }, - run: (ctx) => performPrompt(ctx, params), + run: (ctx) => refusingNewerOrcaJournal(performPrompt(ctx, params)), replay: (_ctx, _outcome, receipt) => acceptedPromptAnswer(receipt) } } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts index 6b596eff2d53..87ad1fc95a11 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-prompt-answer-receipts.test.ts @@ -10,7 +10,10 @@ import { } from '../../../shared/agent-session-journal-types' import { agentJournalItemKey } from '../../../shared/agent-session-journal-item-key' import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store' -import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' +import { + openTestJournalHostDatabase, + SAVED_BY_NEWER_ORCA +} from '../agent-session-journal/journal-host-database-test-support' import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter' import type { StructuredAgentSessionHost } from './structured-agent-session-host' import { @@ -236,6 +239,17 @@ describe('a prompt answer', () => { expect(await resolution(prompt.itemId)).toBe('pending') }) + it("refuses with the update words when a newer Orca's journal holds the chat", async () => { + const prompt = await seeded() + const id = opId() + Object.defineProperty(openTestJournalHostDatabase(root), 'readOnly', { value: true }) + expect(await host.respondToPrompt(CALLER, answer(prompt, id))).toMatchObject({ + ok: false, + ...SAVED_BY_NEWER_ORCA + }) + expect(receipt(id)).toEqual({ verdict: 'absent' }) + }) + it('records nothing for a refusal before acceptance', async () => { const prompt = await seeded() const id = opId() diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts index 294097c4ef0c..25e20130974e 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-queued-mutations.ts @@ -15,10 +15,7 @@ import type { } from '../../../shared/agent-session-wire' import type { AgentSessionJournal } from '../agent-session-journal/journal-store' import { QueuedMessageNotConsumableError } from '../agent-session-journal/journal-queued-messages' -import { - isJournalWrittenByNewerOrca, - journalOpenRefusal -} from '../agent-session-journal/journal-open-failure' +import { refusingNewerOrcaJournal } from '../agent-session-journal/journal-open-failure' import type { QueuedMessageRow } from '../agent-session-journal/queued-message-table' import { journalRowReceiptResult, @@ -106,20 +103,6 @@ function mutateQueued( ) } -/** A newer Orca's journal refuses a draft write with the words a send gets there. */ -async function refusingNewerOrcaJournal( - run: Promise> -): Promise> { - try { - return await run - } catch (error) { - if (isJournalWrittenByNewerOrca(error)) { - return { ok: false, refusal: journalOpenRefusal(error) } - } - throw error - } -} - /** * Send-now. It overrides ONLY queue policy — FIFO order, pause, the busy-turn * wait — through the same send block and pending-prompt gates as any send;